forked from wangziqi/gongxue-base
feat: add commission settlement workflow
This commit is contained in:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user