feat(M05-D): 完成收款配置与幂等分账

This commit is contained in:
Codex
2026-06-22 11:49:37 +08:00
parent 52edf2482e
commit 1680d734bf
16 changed files with 1203 additions and 18 deletions
@@ -31,6 +31,8 @@ import {
import {
PaymentError, PaymentRepository
} from '../dist/payments/payment-repository.js';
import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js';
import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import {
@@ -44,6 +46,7 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_collection_accounts',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
@@ -65,6 +68,8 @@ const expectedTables = [
'qipai_payments',
'qipai_permissions',
'qipai_platform_apps',
'qipai_profit_share_policies',
'qipai_profit_share_receivers',
'qipai_profit_shares',
'qipai_reconciliation_runs',
'qipai_refunds',
@@ -110,12 +115,12 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217']
'2026062015', '2026062216', '2026062217', '2026062218']
);
return rows;
}
@@ -1340,6 +1345,175 @@ async function assertThirdPartyDomain(pool, context) {
assert.equal(unmapped.status, 'PENDING_MAPPING');
}
async function assertProfitSharingDomain(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const [paymentRows] = await pool.query(
`SELECT p.id, p.order_id AS orderId, p.store_id AS storeId,
p.amount_cents AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.provider = 'GROUP_BUY'
AND p.status = 'SUCCEEDED' ORDER BY p.id DESC LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const payment = paymentRows[0];
await pool.query(
`INSERT INTO qipai_payments
(tenant_id, platform_app_id, order_id, store_id, payment_no,
channel, provider, client_request_id, status, amount_cents,
provider_payment_id, paid_at)
VALUES (?, ?, ?, ?, 'M05D-WECHAT-PAYMENT', 'WECHAT', 'WECHAT',
'm05d-wechat-payment', 'SUCCEEDED', ?, 'wx-m05d-transaction',
UTC_TIMESTAMP(3))`,
[context.tenantId, context.platformAppId, payment.orderId,
payment.storeId, payment.amountCents]
);
const [wechatPaymentRows] = await pool.query(
`SELECT id FROM qipai_payments
WHERE tenant_id = ? AND client_request_id = 'm05d-wechat-payment'`,
[context.tenantId]
);
const access = {
roles: ['TENANT_ADMIN'],
capabilities: ['tenant.manage'],
storeIds: []
};
const service = new ProfitSharingService(
pool,
new WechatPayClient({
async request() {
throw new Error('M05-D MySQL test uses the explicit mock adapter only.');
}
}),
new Map(),
true
);
await assert.rejects(
() => service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'PENDING',
profitSharingEnabled: true,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARING_NOT_AUTHORIZED'
);
const account = await service.saveCollectionAccount({
tenantId: context.tenantId,
platformAppId: context.platformAppId,
actorId: adminId,
access,
storeId: String(payment.storeId),
merchantId: '1900000109',
credentialRef: 'env:WX_M05D',
authorizationStatus: 'AUTHORIZED',
profitSharingEnabled: true,
enabled: true
});
const receiverA = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-a',
receiverCredentialRef: 'receiver:STORE_A',
relationType: 'PARTNER',
name: 'M05D Receiver A',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
const receiverB = await service.saveReceiver({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
receiverType: 'MERCHANT_ID',
receiverAccount: 'receiver-private-account-b',
receiverCredentialRef: 'receiver:STORE_B',
relationType: 'PARTNER',
name: 'M05D Receiver B',
authorizationStatus: 'AUTHORIZED',
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverA.receiverId,
percentageBps: 3000,
enabled: true
});
await service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 2000,
enabled: true
});
await assert.rejects(
() => service.savePolicy({
tenantId: context.tenantId,
access,
collectionAccountId: account.accountId,
storeId: String(payment.storeId),
receiverId: receiverB.receiverId,
percentageBps: 8000,
enabled: true
}),
(error) => error.code === 'PROFIT_SHARE_TOTAL_EXCEEDED'
);
const result = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(result.shares.length, 2);
assert.equal(result.shares.every((share) => share.status === 'SUCCEEDED'), true);
const duplicate = await service.execute({
tenantId: context.tenantId,
actorId: adminId,
access,
paymentId: String(wechatPaymentRows[0].id),
clientRequestId: 'm05d-profit-share-request',
mode: 'MOCK'
});
assert.equal(duplicate.idempotent, true);
assert.equal(duplicate.shares.length, 2);
const [shareRows] = await pool.query(
`SELECT ps.status, ps.percentage_bps AS percentageBps,
ps.amount_cents AS amountCents, ps.receiver_ref AS receiverMasked,
r.receiver_hash AS receiverHash, r.receiver_credential_ref AS receiverRef
FROM qipai_profit_shares ps
INNER JOIN qipai_profit_share_receivers r
ON r.tenant_id = ps.tenant_id AND r.id = ps.receiver_id
WHERE ps.tenant_id = ? AND ps.batch_request_id = ?
ORDER BY ps.id`,
[context.tenantId, 'm05d-profit-share-request']
);
assert.equal(shareRows.length, 2);
assert.deepEqual(shareRows.map((row) => row.percentageBps), [3000, 2000]);
assert.equal(shareRows.every((row) => row.status === 'SUCCEEDED'), true);
assert.equal(shareRows.every((row) => /^[a-f0-9]{64}$/.test(row.receiverHash)), true);
assert.equal(shareRows.some((row) => row.receiverMasked.includes('private')), false);
assert.equal(shareRows.every((row) => row.receiverRef.startsWith('receiver:')), true);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -1444,7 +1618,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1460,13 +1635,14 @@ try {
await assertOrderShares(pool, loginContext);
await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M05-C tables.');
console.log('PASS: down removed all M01-B through M05-D tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1488,7 +1664,8 @@ try {
{ version: '2026062014', name: 'm04d_order_shares' },
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' }
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1576,6 +1753,11 @@ try {
'third-party booking webhook idempotency',
'mapped booking claim creates a paid order',
'unmapped booking enters manual queue'
,
'collection account authorization gate',
'profit-share percentage total validation',
'receiver hash and masked storage',
'payment and receiver idempotent profit sharing'
]
}, null, 2));
} finally {