test(order): verify qualification and isolated negative balance gates
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user