test(order): verify qualification and isolated negative balance gates

This commit is contained in:
2026-10-03 00:27:45 +08:00
parent c0c6e21833
commit 9803dcfd2c
2 changed files with 232 additions and 9 deletions
@@ -28,6 +28,78 @@ function runtime(extra = {}) {
}
const plain = value => JSON.parse(JSON.stringify(value))
function balanceGateHarness(fault='') {
const calls=[],ledger=[],report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}]}
const wallet={currencyCode:'CNY',version:0,creditActive:false,actualBalance:0,availableBalance:0,frozenBalance:0,
creditLimit:0,creditUsed:0,creditFrozen:0,availableCredit:0}
const detail={order:{id:'42',tenantId:'20',customerId:'20',orderNo:'FL42',status:'DRAFT',version:3},cargoes:[{skuNo:'SKU'}],parties:[],internalNotes:[],statusHistory:[]}
let sequence=0,task=null
if(fault==='other-customer')report.steps[0].evidence.tenantId='21'
if(fault==='funded-account')wallet.actualBalance=1
const r=runtime({report,marker:'isolated-fixture',merchantToken:'owner',platformToken:'platform',newIdempotencyKey:()=>`key-${++sequence}`,
orderAcceptanceFixture:{payload:{cargoes:[{skuNo:'SKU'}]}},
apiStep:async(_module,name,request,validate)=>{
calls.push({name,request:structuredClone(request)})
let data=null,code='200',message='success'
if(request.path.endsWith('/tenant/profile'))data={id:'20',status:'ACTIVE'}
else if(request.path==='/api/client/finance/v1/account')data=structuredClone(wallet)
else if(request.path.includes('/account/ledger'))data={total:ledger.length,records:structuredClone(ledger)}
else if(request.path.endsWith('/adjustments')) {
assert.equal(request.path,'/api/platform/finance/v1/accounts/20/adjustments')
assert.equal(request.token,'platform');assert.ok(request.headers['Idempotency-Key'])
assert.equal(request.body.amount,0.01);assert.equal(request.body.expectedAccountVersion,wallet.version)
const deduct=request.body.type==='DEDUCTION'
if(deduct&&fault==='deduction-not-applied')throw new Error('not applied')
wallet.actualBalance+=deduct?-0.01:0.01;wallet.availableBalance=wallet.actualBalance;wallet.version++
ledger.push({sourceType:deduct?'MANUAL_DEDUCTION':'MANUAL_RECHARGE',referenceNo:request.body.referenceNo,amount:deduct?-0.01:0.01})
if(deduct&&fault==='lost-deduction-response')throw new Error('response lost')
data=ledger.at(-1)
} else if(request.method==='POST'&&request.path==='/api/merchant/v1/orders/first-leg')data='42'
else if(request.path.includes('/submit')) {
assert.equal(wallet.actualBalance,-0.01)
assert.equal(request.body.expectedVersion,3)
assert.ok(request.headers['Idempotency-Key'])
code='A0403';message=fault==='wrong-gate'?'企业未审核':'账户实际余额为负,请充值后再下单'
if(fault==='order-drift')detail.order.version++
if(fault==='task-drift')task={orderId:'42',tenantId:'20',id:'unexpected'}
} else if(request.path.includes('/tasks/by-order/'))data=structuredClone(task)
else if(request.path.includes('/inbounds?'))data={page:1,total:0,records:[]}
else if(request.path==='/api/merchant/v1/orders/first-leg/42')data=structuredClone(detail)
else throw new Error(`unhandled offline request ${request.path}`)
const response={status:200,json:{code,message,data}};validate(response);return response
},
})
return {r,calls,report,wallet,ledger}
}
test('negative actual funds fixture touches only the new registered customer and restores an audited cent',async()=>{
const {r,calls,report,wallet,ledger}=balanceGateHarness()
await r.orderSubmissionBalanceGateModule()
assert.equal(wallet.actualBalance,0);assert.equal(wallet.version,2);assert.equal(ledger.length,2)
assert.equal(report.orderSubmissionGates.balance.status,'REJECTIONS_VERIFIED')
assert.equal(report.orderSubmissionGates.balance.restoration,'RESTORED_WITH_AUDIT')
assert.equal(calls.filter(call=>call.request.path.includes('/submit')).length,2)
assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,2)
assert.ok(calls.every(call=>!call.request.path.includes('channels')&&!call.request.path.includes('price-books')))
})
for(const fault of ['other-customer','funded-account'])test(`financial fixture refuses unsafe scope: ${fault}`,async()=>{
const {r,calls}=balanceGateHarness(fault)
await assert.rejects(r.orderSubmissionBalanceGateModule())
assert.equal(calls.some(call=>call.request.method==='POST'),false)
})
for(const fault of ['wrong-gate','order-drift','task-drift','lost-deduction-response'])test(`gate evidence fails and restores its proven deduction after ${fault}`,async()=>{
const {r,wallet,ledger}=balanceGateHarness(fault)
await assert.rejects(r.orderSubmissionBalanceGateModule())
assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,2)
})
test('unapplied deduction does not trigger a blind restoring credit',async()=>{
const {r,wallet,ledger,calls}=balanceGateHarness('deduction-not-applied')
await assert.rejects(r.orderSubmissionBalanceGateModule(),/not applied/)
assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,0)
assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,1)
})
test('frozen edit command preserves original version, key and nested payload through retries', () => {
const r = runtime(), snapshot = { order: { version: 7 } }, payload = { draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } }
const command = r.orderMutationCommand(snapshot, payload, 'original-key')
+160 -9
View File
@@ -91,6 +91,7 @@ let temporaryOwnerPassword
try {
await registrationModule()
await platformReviewerLogin()
await merchantBeforeApprovalModule()
await qualificationAndReviewModule()
await merchantAfterApprovalModule()
@@ -99,6 +100,7 @@ try {
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderSubmissionBalanceGateModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await notificationInboxModule()
@@ -219,13 +221,29 @@ async function merchantBeforeApprovalModule() {
assertEqual(String(json.data), String(orderId), 'replayed order id')
}, ({ json }) => ({ orderId: json.data, replayed: true }))
const qualificationBefore = await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId)
const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'before qualification rejection')
await apiStep('order-before-approval', 'submit is blocked before approval', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')),
...orderMutationCommand(qualificationBefore, {}, newIdempotencyKey('pre-approval-submit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code')
}, ({ json }) => ({ orderId, rejectionCode: json.code }))
await ownedOrderSnapshot('order-before-approval', 'qualification rejection preserves complete order facts', orderId, qualificationBefore)
assertOrderUnchanged(qualificationFulfillmentBefore,
await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'after qualification rejection'))
report.qualificationRejectionEvidence = { orderId, orderSha256: orderDigest(qualificationBefore), fulfillmentSha256: orderDigest(qualificationFulfillmentBefore) }
}
async function platformReviewerLogin() {
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
}
async function qualificationAndReviewModule() {
@@ -305,14 +323,6 @@ async function qualificationAndReviewModule() {
assertEqual(json.data?.status, 'PENDING', 'tenant status')
}, ({ json }) => ({ tenantId: json.data.id, status: json.data.status }))
const platformLogin = await apiStep('qualification-review', 'platform reviewer login', {
method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization`
for (let attempt = 0; attempt < 30; attempt += 1) {
const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken })
@@ -2313,6 +2323,147 @@ async function orderMutationEvidenceModule() {
]
}
async function submissionGateFulfillmentSnapshot(order, name) {
const module = 'order-submission-gates', customerId = String(report.resources.tenantId)
let task
await apiStep(module, `${name}: exact fulfillment task`, {
path: `/api/platform/v1/first-leg/tasks/by-order/${order.id}?tenantId=${customerId}`, token: platformToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'task lookup HTTP')
assertTrue(['200','A0404'].includes(String(json?.code)), 'task lookup must succeed or explicitly not exist')
task = String(json.code) === 'A0404' ? null : json.data ?? null
if (task) {
assertEqual(String(task.orderId), String(order.id), 'task source order')
assertEqual(String(task.tenantId), customerId, 'task customer')
}
}, () => ({ orderId: order.id, taskSha256: orderDigest(task) }))
const inbounds = []
let total
for (let page = 1; ; page++) {
await apiStep(module, `${name}: inbound page ${page}`, {
path: `/api/platform/inbounds?tenantId=${customerId}&orderNo=${encodeURIComponent(order.orderNo)}&page=${page}&size=200`, token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'inbound records required')
assertTrue(Number.isSafeInteger(Number(json.data.total)) && Number(json.data.total) >= 0, 'inbound total required')
if (total === undefined) total = Number(json.data.total)
assertEqual(Number(json.data.total), total, 'stable inbound total')
assertEqual(Number(json.data.page), page, 'inbound page identity')
for (const row of json.data.records) {
assertEqual(row.orderNo, order.orderNo, 'exact inbound source')
assertEqual(String(row.tenantId), customerId, 'inbound customer')
assertTrue(!inbounds.some(before => String(before.id) === String(row.id)), 'no duplicate inbound across pages')
inbounds.push(row)
}
assertEqual(json.data.records.length, Math.min(200, Math.max(0, total - (page - 1) * 200)), 'complete inbound page')
}, () => ({ orderId: order.id, total, collected: inbounds.length, snapshotSha256: orderDigest(inbounds) }))
if (page * 200 >= total) break
}
return { task, inbounds }
}
async function orderSubmissionBalanceGateModule() {
const module = 'order-submission-gates', customerId = String(report.resources.tenantId)
const proof = report.orderSubmissionGates = { customerId, balance: { status: 'PREPARING' }, remaining: [
'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.',
'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.',
] }
await apiStep(module, 'guard newly registered customer ownership before financial fixture', {
path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.id), customerId, 'current primary client identity')
assertEqual(json.data?.status, 'ACTIVE', 'approved active customer')
assertTrue(report.steps.some(step => step.module === 'registration' && step.name === 'enterprise self-registration'
&& step.passed === true && String(step.evidence?.tenantId) === customerId), 'only this run newly registered customer may be adjusted')
}, () => ({ customerId, scope: 'current-run-registration-only' }))
const readWallet = async label => {
const response = await apiStep(module, label, { path: '/api/client/finance/v1/account', token: merchantToken }, ({ json }) => {
requireSuccess(json); assertTrue(Number.isSafeInteger(json.data?.version), 'wallet version required')
assertEqual(json.data.currencyCode, 'CNY', 'isolated wallet currency')
for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) {
assertTrue(json.data[field] != null && Number.isFinite(Number(json.data[field])), `wallet ${field} required`)
}
assertEqual(json.data.creditActive, false, 'no active credit may be altered')
}, ({ json }) => ({ customerId, account: json.data }))
return response.json.data
}
const readLedger = async label => {
const response = await apiStep(module, label, { path: '/api/client/finance/v1/account/ledger?page=1&size=200', token: merchantToken }, ({ json }) => {
requireSuccess(json); assertTrue(Array.isArray(json.data?.records), 'ledger records required')
assertEqual(Number(json.data.total), json.data.records.length, 'entire isolated ledger fits one page')
}, ({ json }) => ({ total: json.data.total, ledgerSha256: orderDigest(json.data.records) }))
return response.json.data.records
}
const baseline = await readWallet('freeze pristine account')
for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(baseline[field]), 0, `pristine ${field}`)
assertEqual((await readLedger('require no prior ledger')).length, 0, 'never debit an existing funded account')
const payload = { ...structuredClone(orderAcceptanceFixture.payload), customerRemark: `BALANCE-GATE-${marker}` }
const created = await apiStep(module, 'create only one fresh balance-gate draft', {
method:'POST', path:'/api/merchant/v1/orders/first-leg', token:merchantToken, body:payload,
headers:{'Idempotency-Key':newIdempotencyKey('balance-gate-create')},
}, ({json})=>{requireSuccess(json);assertPresent(json.data,'gate order')}, ({json})=>({orderId:json.data}))
const orderId=created.json.data
report.resources.orderIds.push(orderId);proof.balance.orderId=orderId
const before = await ownedOrderSnapshot(module,'freeze rejected-submit draft facts',orderId)
assertEqual(before.order.status,'DRAFT','unfulfilled balance fixture')
const fulfillmentBefore = await submissionGateFulfillmentSnapshot(before.order,'before balance rejection')
const occurredDate = new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())
const referenceNo = `BALANCE-GATE-${marker}`.slice(0,128)
const adjust = async (type, account, suffix) => {
const command={headers:{'Idempotency-Key':newIdempotencyKey(`balance-${suffix}`)},body:{type,amount:0.01,occurredDate,paymentMethod:'OTHER',
referenceNo,remark:`${marker} isolated order gate ${suffix}`,expectedAccountVersion:account.version}}
proof.balance[`${suffix}Command`]=command
await apiStep(module,`isolated wallet ${suffix}`,{method:'POST',path:`/api/platform/finance/v1/accounts/${customerId}/adjustments`,
token:platformToken,...command},({json})=>requireSuccess(json),({json})=>({customerId,ledger:json.data}))
}
let attempted=false
try {
attempted=true
await adjust('DEDUCTION',baseline,'deduct')
const negative=await readWallet('verify actual balance minus one cent')
assertEqual(Number(negative.actualBalance),-0.01,'negative actual funds, not insufficient available credit')
assertEqual(negative.version,baseline.version+1,'one intentional deduction')
const negativeLedger=await readLedger('freeze deduction ledger')
assertEqual(negativeLedger.length,1,'only one fixture deduction')
for(const role of ['merchant','platform']) {
const command=orderMutationCommand(before,{},newIdempotencyKey(`negative-balance-${role}`))
await apiStep(module,`${role} negative actual balance rejects submission`,{
method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`,
token:role==='merchant'?merchantToken:platformToken,...command,
},({json,status})=>{assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0403','balance gate code');
assertEqual(json?.message,'账户实际余额为负,请充值后再下单','balance-specific reason, not qualification rejection')},()=>({orderId,customerId,expectedVersion:command.body.expectedVersion}))
await ownedOrderSnapshot(module,`${role} rejected order aggregate unchanged`,orderId,before)
const after=await submissionGateFulfillmentSnapshot(before.order,`${role} rejection`)
assertOrderUnchanged(fulfillmentBefore,after)
assertOrderUnchanged(negative,await readWallet(`${role} rejection leaves wallet unchanged`))
assertOrderUnchanged(negativeLedger,await readLedger(`${role} rejection leaves ledger unchanged`))
}
proof.balance.status='REJECTIONS_VERIFIED'
} finally {
if(attempted) {
const current=await readWallet('resolve isolated deduction before restoring')
const ledger=await readLedger('resolve exact fixture ledger before restoring')
if(Number(current.actualBalance)===0 && current.version===baseline.version && ledger.length===0) proof.balance.restoration='DEDUCTION_NOT_APPLIED'
else {
assertEqual(Number(current.actualBalance),-0.01,'restore only exact isolated negative balance')
assertEqual(current.version,baseline.version+1,'restore requires unchanged deduction version')
assertTrue(ledger.length===1 && ledger[0].sourceType==='MANUAL_DEDUCTION' && ledger[0].referenceNo===referenceNo
&& Number(ledger[0].amount)===-0.01,'restore only this run proven deduction; no blind credit')
await adjust('RECHARGE',current,'restore')
const restored=await readWallet('verify restored zero balance')
for(const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(restored[field]),Number(baseline[field]),`restored ${field}`)
assertEqual(restored.version,baseline.version+2,'one deduction and one restoring credit')
const finalLedger=await readLedger('verify exact two fixture ledger entries')
assertEqual(finalLedger.length,2,'no extra wallet movements')
assertTrue(finalLedger.every(row=>row.referenceNo===referenceNo) && finalLedger.filter(row=>row.sourceType==='MANUAL_DEDUCTION'&&Number(row.amount)===-0.01).length===1
&& finalLedger.filter(row=>row.sourceType==='MANUAL_RECHARGE'&&Number(row.amount)===0.01).length===1,'exact balanced fixture movements')
proof.balance.restoration='RESTORED_WITH_AUDIT'
}
}
}
}
async function importOrderSet(name, expectedIds) {
const ids = [], seen = new Set()
let total = null