forked from wangziqi/gongxue-base
feat: add commerce reconciliation ledger
This commit is contained in:
@@ -2044,6 +2044,175 @@ async function testCommerce() {
|
||||
});
|
||||
assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated');
|
||||
|
||||
const studentReconciliationDenied = await request('/api/commerce/reconciliation/batches', {
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance reconciliation');
|
||||
|
||||
const reconMatchedOrder = await request('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
planId: ids.plan,
|
||||
payProvider: 'manual',
|
||||
payMethod: 'manual',
|
||||
regionId: ids.region,
|
||||
},
|
||||
});
|
||||
await request('/api/commerce/payments/manual-confirm', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: reconMatchedOrder.item.orderNo,
|
||||
amountCents: reconMatchedOrder.item.amountCents,
|
||||
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
|
||||
},
|
||||
});
|
||||
|
||||
const reconMissingProviderOrder = await request('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
planId: ids.plan,
|
||||
payProvider: 'manual',
|
||||
payMethod: 'manual',
|
||||
regionId: ids.region,
|
||||
},
|
||||
});
|
||||
await request('/api/commerce/payments/manual-confirm', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: reconMissingProviderOrder.item.orderNo,
|
||||
amountCents: reconMissingProviderOrder.item.amountCents,
|
||||
providerTradeNo: `manual-recon-${reconMissingProviderOrder.item.orderNo}`,
|
||||
},
|
||||
});
|
||||
|
||||
const billDate = shanghaiDateKey();
|
||||
const reconciliationRows = [
|
||||
{
|
||||
transactionType: 'payment',
|
||||
orderNo: reconMatchedOrder.item.orderNo,
|
||||
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
|
||||
amountCents: reconMatchedOrder.item.amountCents,
|
||||
providerStatus: 'SUCCESS',
|
||||
},
|
||||
{
|
||||
transactionType: 'payment',
|
||||
orderNo: reconMatchedOrder.item.orderNo,
|
||||
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
|
||||
amountCents: reconMatchedOrder.item.amountCents,
|
||||
providerStatus: 'SUCCESS',
|
||||
},
|
||||
{
|
||||
transactionType: 'payment',
|
||||
orderNo: reconMatchedOrder.item.orderNo,
|
||||
providerTradeNo: `manual-recon-mismatch-${reconMatchedOrder.item.orderNo}`,
|
||||
amountCents: reconMatchedOrder.item.amountCents + 1,
|
||||
providerStatus: 'SUCCESS',
|
||||
},
|
||||
{
|
||||
transactionType: 'payment',
|
||||
orderNo: 'PROVIDER-ONLY-ORDER',
|
||||
providerTradeNo: 'provider-only-trade',
|
||||
amountCents: 1888,
|
||||
providerStatus: 'SUCCESS',
|
||||
},
|
||||
];
|
||||
|
||||
const reconciliationPreview = await request('/api/commerce/reconciliation/preview', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
provider: 'manual',
|
||||
billDate,
|
||||
billType: 'payment',
|
||||
sourceName: 'integration-manual-bill.json',
|
||||
rows: reconciliationRows,
|
||||
previewLimit: 20,
|
||||
},
|
||||
});
|
||||
assert.equal(reconciliationPreview.item?.status, 'preview', 'reconciliation preview should not persist a completed batch');
|
||||
assert.ok(
|
||||
reconciliationPreview.items?.some(item => item.orderNo === reconMatchedOrder.item.orderNo && item.matchStatus === 'matched'),
|
||||
'reconciliation preview should match a provider payment row to local payment',
|
||||
);
|
||||
assert.ok(
|
||||
reconciliationPreview.items?.some(item => item.matchStatus === 'duplicate'),
|
||||
'reconciliation preview should detect duplicate provider rows',
|
||||
);
|
||||
assert.ok(
|
||||
reconciliationPreview.items?.some(item => item.matchStatus === 'amount_mismatch'),
|
||||
'reconciliation preview should detect amount mismatches',
|
||||
);
|
||||
assert.ok(
|
||||
reconciliationPreview.items?.some(item => item.matchStatus === 'missing_local'),
|
||||
'reconciliation preview should detect provider rows missing local orders',
|
||||
);
|
||||
assert.ok(
|
||||
reconciliationPreview.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo && item.matchStatus === 'missing_provider'),
|
||||
'reconciliation preview should detect local paid orders missing from provider bill',
|
||||
);
|
||||
|
||||
const reconciliationImport = await request('/api/commerce/reconciliation/import', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
provider: 'manual',
|
||||
billDate,
|
||||
billType: 'payment',
|
||||
sourceName: 'integration-manual-bill.json',
|
||||
rows: reconciliationRows,
|
||||
metadata: { test: 'commerce-reconciliation' },
|
||||
previewLimit: 20,
|
||||
},
|
||||
});
|
||||
assert.ok(reconciliationImport.item?.id, 'tenant admin should import reconciliation batch');
|
||||
assert.equal(
|
||||
reconciliationImport.item?.status,
|
||||
'completed_with_issues',
|
||||
'reconciliation import should mark batches with anomalies as completed_with_issues',
|
||||
);
|
||||
assert.ok(reconciliationImport.item?.matchedCount >= 1, 'reconciliation batch should count matched rows');
|
||||
assert.ok(reconciliationImport.item?.mismatchCount >= 1, 'reconciliation batch should count mismatch rows');
|
||||
assert.ok(reconciliationImport.item?.missingLocalCount >= 1, 'reconciliation batch should count missing local rows');
|
||||
assert.ok(reconciliationImport.item?.missingProviderCount >= 1, 'reconciliation batch should count missing provider rows');
|
||||
assert.ok(reconciliationImport.item?.duplicateCount >= 1, 'reconciliation batch should count duplicate rows');
|
||||
|
||||
const reconciliationBatches = await request('/api/commerce/reconciliation/batches', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { provider: 'manual', billDate },
|
||||
});
|
||||
assert.ok(
|
||||
reconciliationBatches.items?.some(item => item.id === reconciliationImport.item.id),
|
||||
'reconciliation batches endpoint should list imported batch',
|
||||
);
|
||||
|
||||
const reconciliationItems = await request('/api/commerce/reconciliation/items', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { batchId: reconciliationImport.item.id, matchStatus: 'missing_provider' },
|
||||
});
|
||||
assert.ok(
|
||||
reconciliationItems.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo),
|
||||
'reconciliation items endpoint should filter missing provider rows',
|
||||
);
|
||||
|
||||
const reconciliationAnomalies = await request('/api/commerce/reconciliation/anomalies', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { provider: 'manual' },
|
||||
});
|
||||
assert.ok(
|
||||
reconciliationAnomalies.items?.some(item => item.batchId === reconciliationImport.item.id && item.matchStatus === 'missing_local'),
|
||||
'reconciliation anomalies endpoint should expose imported issues',
|
||||
);
|
||||
|
||||
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { batchId: reconciliationImport.item.id },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(crossTenantReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation items must be tenant isolated');
|
||||
|
||||
const fakeWechatPay = await startFakeWechatPayServer();
|
||||
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -5583,6 +5752,8 @@ async function testTenantMemberPermissionsAndAudit() {
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission');
|
||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission');
|
||||
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
|
||||
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read');
|
||||
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');
|
||||
|
||||
Reference in New Issue
Block a user