feat: add commerce reconciliation ledger

This commit is contained in:
Codex
2026-06-29 18:46:38 +08:00
parent 79d0d786a0
commit 7258e4a7d5
14 changed files with 1555 additions and 15 deletions

View File

@@ -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');