feat: add commission settlement workflow

This commit is contained in:
Codex
2026-06-29 04:18:17 +08:00
parent 0bca1f00a9
commit 0cff0d102d
13 changed files with 1271 additions and 12 deletions

View File

@@ -155,6 +155,18 @@ function getFreePort() {
});
}
function shanghaiDateKey(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
async function waitForHealth(timeoutMs = 12_000) {
const started = Date.now();
let lastError = null;
@@ -3757,6 +3769,202 @@ async function testReferralAndCrmGrowth() {
});
assert.ok(teamList.items?.some(item => item.memberUserId === TENANT_AGENT_USER_ID), 'sales should see own agent team');
const commissionSettings = await request('/api/commission/settings', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
defaultRate: 0.2,
minSettlementCents: 0,
settlementCycle: 'monthly',
config: { source: 'integration-test' },
},
});
assert.equal(Number(commissionSettings.item?.defaultRate), 0.2, 'tenant admin should configure default commission rate');
const agentRate = await request('/api/commission/member-rate', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
userId: TENANT_AGENT_USER_ID,
commissionRate: 0.35,
commissionConfig: { note: 'integration agent rate' },
},
});
assert.equal(Number(agentRate.item?.commissionRate), 0.35, 'tenant admin should configure agent commission rate');
const commissionOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
assert.equal(commissionOrder.item?.status, 'pending', 'commission smoke order should start pending');
const commissionPaid = await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: commissionOrder.item.orderNo,
amountCents: commissionOrder.item.amountCents,
providerTradeNo: `commission-${commissionOrder.item.orderNo}`,
},
});
assert.equal(commissionPaid.item?.status, 'paid', 'commission smoke order should be paid');
const commissionBatch = await request('/api/tenant-admin/code-batches', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
name: '集成测试分佣批次',
saleType: 'agent',
defaultUnitPriceCents: 2000,
days: 30,
regionId: ids.region,
commissionRate: 0.45,
},
});
assert.equal(Number(commissionBatch.item?.commissionRate), 0.45, 'code batch should persist commission rate');
const commissionCode = await request('/api/tenant-admin/activation-codes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'IT-COMMISSION-001',
days: 30,
batchId: commissionBatch.item.id,
unitPriceCents: 2000,
usedRegionId: ids.region,
remark: 'commission integration code',
},
});
assert.equal(commissionCode.item?.code, 'IT-COMMISSION-001', 'tenant admin should create commission activation code');
const commissionRedeemed = await request('/api/commerce/activation-codes/redeem', {
method: 'POST',
body: { code: 'IT-COMMISSION-001', regionId: ids.region },
});
assert.ok(commissionRedeemed.item?.entitlement?.id, 'commission activation code should be redeemable');
const today = shanghaiDateKey();
const agentCommissionSummary = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.equal(agentCommissionSummary.item?.referrerUserId, TENANT_AGENT_USER_ID, 'agent commission summary should be scoped to self');
assert.equal(agentCommissionSummary.item?.sourceCount >= 2, true, 'agent commission summary should include order and activation code');
assert.equal(agentCommissionSummary.item?.grossAmountCents >= 2990, true, 'agent commission summary should include gross amount');
assert.equal(agentCommissionSummary.item?.commissionAmountCents >= 1217, true, 'agent commission should apply member and batch rates');
const salesOtherCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_SALES_USER_ID },
expectStatus: 403,
});
assert.equal(salesOtherCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'agent should not read another referrer commission');
const salesAgentCommissionDenied = await request('/api/commission/summary', {
userId: TENANT_SALES_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID },
expectStatus: 403,
});
assert.equal(salesAgentCommissionDenied.code, 'COMMISSION_SCOPE_REQUIRED', 'sales should not read agent commission without commission:read');
const commissionOrders = await request('/api/commission/orders', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: today, endDate: today, referrerUserId: TENANT_AGENT_USER_ID, limit: 20 },
});
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'order' && item.rateSource === 'member'), 'commission orders should include paid order with member rate');
assert.ok(commissionOrders.items?.some(item => item.sourceType === 'activation_code' && item.rateSource === 'batch'), 'commission orders should include activation code with batch rate');
assert.ok(!JSON.stringify(commissionOrders).includes('crm-secret-smoke'), 'commission order list must not leak CRM secret');
const generatedSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
status: 'pending_review',
remark: 'integration settlement',
},
});
assert.equal(generatedSettlement.item?.referrerUserId, TENANT_AGENT_USER_ID, 'tenant admin should generate agent settlement');
assert.equal(generatedSettlement.item?.status, 'pending_review', 'generated settlement should enter review flow');
assert.equal(generatedSettlement.item?.sourceCount >= 2, true, 'generated settlement should include unsettled sources');
const duplicateSettlement = await request('/api/commission/settlements/generate', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
referrerUserId: TENANT_AGENT_USER_ID,
startDate: today,
endDate: today,
},
expectStatus: 409,
});
assert.equal(duplicateSettlement.code, 'COMMISSION_NO_UNSETTLED_SOURCES', 'settlement generation should not duplicate settled sources');
const approvedSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'approved',
remark: 'integration approved',
},
});
assert.equal(approvedSettlement.item?.status, 'approved', 'tenant admin should approve settlement');
assert.equal(approvedSettlement.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'settlement approval should record reviewer');
const paidSettlement = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'paid',
paymentMethod: 'offline_bank',
paymentAccount: 'masked-bank-account',
metadata: { source: 'integration-test' },
},
});
assert.equal(paidSettlement.item?.status, 'paid', 'tenant admin should mark settlement paid');
assert.equal(paidSettlement.item?.paidBy, TENANT_ADMIN_USER_ID, 'settlement paid status should record operator');
assert.equal(paidSettlement.item?.paymentMethod, 'offline_bank', 'settlement paid status should persist payment method');
const paidSettlementLocked = await request('/api/commission/settlements/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
settlementId: generatedSettlement.item.id,
status: 'rejected',
},
expectStatus: 409,
});
assert.equal(paidSettlementLocked.code, 'COMMISSION_SETTLEMENT_LOCKED', 'paid settlement should not be mutable');
const settlementList = await request('/api/commission/settlements', {
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
});
assert.ok(settlementList.items?.some(item => item.id === generatedSettlement.item.id && item.status === 'paid'), 'agent should see own paid settlement');
const studentCommissionDenied = await request('/api/commission/summary', {
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(studentCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access commission summary');
const partnerCommissionDenied = await request('/api/commission/summary', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_AGENT_USER_ID,
query: { startDate: today, endDate: today },
expectStatus: 403,
});
assert.equal(partnerCommissionDenied.code, 'TENANT_ADMIN_REQUIRED', 'commission APIs must be tenant isolated');
const crmQueue = await request('/api/crm/queue', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'pending' },