文件
hyb-oa/scripts/qa/company-loan-http-fixture.cjs
T
wangsli和Claude Opus 5.5 b88790d396 借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)
- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时,
  最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过,
  证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用
- 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、
  回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须
  当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、
  人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改
- 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选
  判断,核销之后才出现的新证据留待晚到结果对账流程处理
- 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认
- 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、
  PC 浏览器完整登记复核重登流程;变异测试均变红

注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认
失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 20:20:51 +08:00

705 行
60 KiB
JavaScript

const assert = require('node:assert/strict');
const path = require('node:path');
const { randomBytes, randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'server/package.json'));
const { PrismaClient } = req('@prisma/client');
const { PrismaPg } = req('@prisma/adapter-pg');
const { hashPassword } = req('./src/utils/crypto');
module.exports = async function runLoanHttpFixture(pool, scratch) {
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
const app = req('fastify')({ logger: false });
const config = {
fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'),
accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false,
rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1',
storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai',
paymentBatchEnabled: true,
};
app.decorate('config', config); app.decorate('prisma', prisma);
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', details: error.details ?? {},
}));
await app.register(req('@fastify/cookie'));
await app.register(req('@fastify/multipart'));
await app.register(req('./src/plugins/auth'));
await app.register(req('./src/routes/auth'), { prefix: '/api/auth' });
await app.register(req('./src/routes/companyLoans'), { prefix: '/api' });
await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' });
for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) {
await app.register(req(`./src/routes/${route}`), { prefix: '/api' });
}
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') });
app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html'));
}
await app.ready();
const checks = [];
try {
const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } });
const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } });
const department = await prisma.department.create({ data: { name: '合成财务部' } });
await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } });
const password = `SyntH!${randomBytes(16).toString('hex')}`;
const passwordHash = await hashPassword(password);
async function actor(roleCodes) {
const id = randomUUID();
const employee = await prisma.employee.create({ data: {
id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id,
legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'),
bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash,
roles: { create: roleCodes.map(role_code => ({ role_code })) },
} });
const login = await app.inject({ method: 'POST', url: '/api/auth/login',
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } });
assert.equal(login.statusCode, 200, 'real synthetic password login');
return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } };
}
const finance = await actor(['finance']);
const manager = await actor(['manager']);
const system = await actor(['system_admin']);
const admin = await actor(['admin']);
const ordinary = await actor(['employee']);
async function call(who, method, url, payload, status = 200, reason) {
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
return response.json().data;
}
for (const who of [manager, system, admin, ordinary]) {
await call(who, 'GET', '/company-loans', undefined, 403);
await call(who, 'POST', '/loan-lenders', {}, 403);
}
await call(null, 'GET', '/company-loans', undefined, 401);
checks.push('real_login_and_explicit_finance_permissions');
const bankAccount = '9'.repeat(16); // synthetic account, never log its value
const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人',
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' };
const lender = await call(finance, 'POST', '/loan-lenders', lenderInput);
assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id);
await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration');
assert.equal(lender.identityStatus, 'unverified');
assert.ok(!JSON.stringify(lender).includes(bankAccount));
assert.ok(!JSON.stringify(lender).includes('v1:'));
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实',
}, 400, 'loan.invalid_input');
const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实',
});
assert.equal(verified.identityStatus, 'verified');
checks.push('lender_encryption_identity_confirmation_and_idempotency');
const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一',
originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05',
receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true };
const loan = await call(finance, 'POST', '/company-loans', input);
assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05');
assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id);
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration');
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400);
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) {
const boundary = `synthetic-${randomUUID()}`;
const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload });
assert.equal(response.statusCode, status, `upload ${kind}`);
return response.json().data;
}
await upload('agreement', Buffer.from('<script>synthetic</script>'), 'bad.pdf', 'application/pdf', 400);
const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation');
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
await upload('adjustment');
const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2);
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable');
checks.push('historical_balance_evidence_and_outside_repayment_required');
await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403);
await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400);
const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`,
headers: finance.headers, payload: { reason: '合成核对读取' } });
assert.equal(downloaded.statusCode, 200);
assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;'));
assert.equal(downloaded.headers['x-content-type-options'], 'nosniff');
assert.ok(downloaded.headers['cache-control'].includes('no-store'));
checks.push('scoped_evidence_download_and_headers');
// Real database audit failure must roll back the source write and not merely log it.
await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()');
const count = await prisma.companyLoan.count();
try {
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500);
assert.equal(await prisma.companyLoan.count(), count);
} finally {
await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_loan_audit()');
}
checks.push('real_audit_failure_atomic_rollback');
const { encryptStringOrThrow } = req('./src/services/crypto');
const payerAccount = '8'.repeat(16);
await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id,
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二',
originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' });
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id);
await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' });
const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本',
allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] };
const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput);
assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15');
assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id);
await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration');
const balance = await call(finance, 'GET', `/company-loans/${loan.id}`);
assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95');
await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403);
await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required');
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready');
checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review');
const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob');
async function receipt(batchId, amount, reference, overrides = {}) {
const boundary = `synthetic-${randomUUID()}`;
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(response.statusCode, 200, 'receipt upload');
const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount,
transactionStatus: 'success', reference, issues: [], ...overrides }] };
// Only the cloud recognizer is replaced. Queue claiming, local storage,
// normalization, verification, transaction claims and HTTP writes are real.
const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition });
return { attachment: response.json().data.items[0], result };
}
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, 'loan bank-order export');
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release');
const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1');
assert.equal(receiptResult.result.status, 'matched');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()');
try {
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500);
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
} finally {
await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()');
}
const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
assert.equal(paid.item.status, 'executed');
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00');
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' });
checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff');
const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] };
const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput);
await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {});
await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1');
await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] };
const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments',
headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } })));
assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]);
const winner = concurrent.find(r => r.statusCode === 200).json().data.item;
await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' });
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] });
await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {});
const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] });
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403);
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id });
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' });
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
checks.push('confirmed_bank_failure_requires_independent_matching_evidence');
const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] });
await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {});
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
assert.equal(unclear.result.status, 'needs_review');
const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } });
const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`;
const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version,
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功',
}).map(([Name, Value]) => ({ Name, Value })) }] });
const saved = proposal.item ?? proposal;
await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403);
await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' });
await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
checks.push('external_lender_two_person_manual_receipt_and_real_settlement');
// Cross-source claim protection: seed only the prior reimbursement facts,
// then use actual receipt upload/worker/execute HTTP. No reimbursement paid
// result is seeded or asserted by this setup.
const source = await prisma.reimbursementForm.create({ data: {
form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id,
department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved',
} });
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } });
const crossBatch = await prisma.paymentBatch.create({ data: {
batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed',
created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id,
payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json,
default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源',
amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } },
} });
await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1');
await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved');
checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http');
for (const [label, overrides] of [
['amount', { amount: '101' }], ['currency', { currency: 'USD' }],
['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }],
['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }],
]) {
const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput,
requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {});
const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides);
assert.equal(mismatch.result.status, 'needs_review');
await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409);
await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409);
await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe');
}
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release');
const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] };
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); }
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput })));
assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]);
assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id);
await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' });
checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent');
const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`;
const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers,
payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } });
assert.equal(reviewerDownload.statusCode, 200);
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403);
// Fresh lenders below: the first lender keeps unknown-state reservations from
// the negative receipt loop above, which by design block its maintenance.
async function verifiedLender(name, employeeId, digit) {
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: digit.repeat(16), accountName: name });
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
return { ...confirmedLender, bankAccount: digit.repeat(16) };
}
async function confirmedLoan(lenderId, principal, ref) {
const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref,
originalPrincipal: principal, openingOutstandingPrincipal: principal, receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true });
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, created.id);
await call(finance, 'POST', `/company-loans/${created.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
return created;
}
const repay = async (lenderId, loanId, amount) => (await call(finance, 'POST', '/loan-repayments', {
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
async function exportOrder(batchId, status = 200, reason) {
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(response.statusCode, status, `export ${response.statusCode} ${status === 200 ? '' : response.body.slice(0, 200)}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
}
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
const independent = await actor(['ceo']);
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
const linked = await verifiedLender('合成出借人乙', manager.id, '4');
const linkedLoan = await confirmedLoan(linked.id, '500.00', '合成乙借入');
const ownPayee = await repay(linked.id, linkedLoan.id, '5.00');
await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403);
await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {});
await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' });
checks.push('scoped_reviewer_materials_and_payee_self_review');
// B1: any reserved allocation freezes lender identity/account maintenance,
// before and after payment-order export, until the repayment settles or is cancelled.
const identityChange = (who, version, status, reason) => call(who, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, {
expectedVersion: version, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成在途改身份' }, status, reason);
const accountChange = (version, status, reason) => call(finance, 'PATCH', `/loan-lenders/${linked.id}/account`, {
expectedVersion: version, bankInstitutionId: bank.id, bankName: '合成变更开户行', bankAccount: '3'.repeat(16), accountName: '合成出借人乙' }, status, reason);
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false);
const inFlight = await repay(linked.id, linkedLoan.id, '5.00');
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, true);
await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight');
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
await call(independent, 'POST', `/payment-batches/${inFlight.id}/review`, {});
await exportOrder(inFlight.id);
await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight');
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
const inFlightReceipt = await receipt(inFlight.id, '500', 'SYNTHETIC-IN-FLIGHT', { payeeAccount: linked.bankAccount });
assert.equal(inFlightReceipt.result.status, 'matched');
assert.equal((await call(finance, 'POST', `/payment-batches/${inFlight.id}/execute`, {})).item.status, 'executed');
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false);
// A never-sent request can be cancelled to release the freeze.
const unsent = await repay(linked.id, linkedLoan.id, '5.00');
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
await call(finance, 'POST', `/payment-batches/${unsent.id}/cancel`, { reason: '合成未发送取消' });
checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export');
// Account change after release revokes identity and blocks new requests until re-verified.
const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion);
assert.equal(changedAccount.identityStatus, 'unverified');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实',
allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] }, 409, 'loan.identity_required');
await identityChange(finance, changedAccount.identityVersion);
checks.push('account_changes_revoke_identity_until_reverified');
// Maintenance racing creation, with deterministic interleaving: a separate
// connection holds the lender row lock, both requests queue behind it in a
// chosen order, then the lock is released. No reservation may keep stale versions.
const { Pool: LockPool } = req('pg');
const lockPool = new LockPool({ ...pool.options, max: 2 });
const lockWaiters = async () => Number((await lockPool.query(
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
async function untilWaiting(count) {
for (let i = 0; i < 200; i++) { if (await lockWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
throw new Error('request did not queue behind the lender lock');
}
try {
for (const first of ['create', 'maintain']) {
const current = await lenderState(linked.id);
const holder = await lockPool.connect();
let created; let maintained;
try {
await holder.query('BEGIN');
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [linked.id]);
const start = { create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: {
requestKey: randomUUID(), lenderId: linked.id, reason: '合成并发', allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] } }),
maintain: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${linked.id}/identity-confirmation`, headers: finance.headers, payload: {
expectedVersion: current.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成并发改身份' } }) };
const second = first === 'create' ? 'maintain' : 'create';
const firstResponse = start[first](); await untilWaiting(1);
const secondResponse = start[second](); await untilWaiting(2);
await holder.query('COMMIT');
[created, maintained] = first === 'create' ? [await firstResponse, await secondResponse] : [await secondResponse, await firstResponse];
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (first === 'create') {
assert.equal(created.statusCode, 200);
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight');
} else {
assert.equal(maintained.statusCode, 200, 'maintenance first succeeds');
assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions');
}
const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: linked.id } });
const stale = await prisma.loanRepayment.count({ where: { lender_id: linked.id, allocations: { some: { reservation_state: 'reserved' } },
OR: [{ lender_identity_version: { not: after.identity_version } }, { lender_account_version: { not: after.account_version } }] } });
assert.equal(stale, 0, `no reserved repayment may hold stale lender versions (${first} first)`);
await call(finance, 'POST', `/payment-batches/${created.json().data.item.id}/cancel`, { reason: '合成并发结束' });
}
} finally { await lockPool.end(); }
checks.push('lender_maintenance_and_creation_deterministic_interleaving_never_leaves_stale_reservation');
// B: after the bank paid, approval-time eligibility decides. A departed
// reviewer or lender employee must not strand settlement via OCR or manual review.
const lenderEmployee = await actor(['employee']);
const staffLender = await verifiedLender('合成出借人丙', lenderEmployee.id, '5');
const staffLoan = await confirmedLoan(staffLender.id, '500.00', '合成丙借入');
const setStatus = (who, status) => prisma.employee.update({ where: { id: who.id }, data: { status } });
const outstanding = async () => (await call(finance, 'GET', `/company-loans/${staffLoan.id}`)).outstanding;
const reviewerA = await actor(['manager']);
const viaOcr = await repay(staffLender.id, staffLoan.id, '10.00');
await call(reviewerA, 'POST', `/payment-batches/${viaOcr.id}/review`, {});
await exportOrder(viaOcr.id);
await setStatus(reviewerA, 'archived'); await setStatus(lenderEmployee, 'archived');
const ocrResult = await receipt(viaOcr.id, '1000', 'SYNTHETIC-POST-PAY-OCR', { payeeAccount: staffLender.bankAccount });
assert.equal(ocrResult.result.status, 'matched', 'OCR must not fall back to legacy after departures');
assert.equal((await call(finance, 'POST', `/payment-batches/${viaOcr.id}/execute`, {})).item.status, 'executed');
assert.equal(await outstanding(), '490.00');
await setStatus(lenderEmployee, 'active');
const reviewerB = await actor(['manager']);
const viaManual = await repay(staffLender.id, staffLoan.id, '20.00');
await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {});
await exportOrder(viaManual.id);
await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived');
const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
assert.equal(manualUnclear.result.status, 'needs_review');
const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } });
const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`;
const manualProposal = await call(finance, 'POST', manualPath, { expectedVersion: manualAttachment.receipt_ocr_version,
reason: '合成付款后人工核对', pages: [{ pageNumber: 1, itemId: viaManual.items[0].id, fields: Object.entries({
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: staffLender.bankAccount, 凭证号: 'SYNTHETIC-POST-PAY-MANUAL', 单据状态: '交易成功',
}).map(([Name, Value]) => ({ Name, Value })) }] });
const manualSaved = manualProposal.item ?? manualProposal;
await call(manager, 'POST', `${manualPath}/${manualSaved.id}/review`, { expectedVersion: manualSaved.version, decision: 'approve', reason: '合成当前独立复核' });
assert.equal((await call(finance, 'POST', `/payment-batches/${viaManual.id}/execute`, {})).item.status, 'executed');
assert.equal(await outstanding(), '470.00');
await setStatus(lenderEmployee, 'active');
checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual');
// Approval-time independence is still enforced after payment: a recorded
// approver equal to the batch creator/submitter or lender employee is refused.
for (const [label, approverId, amount] of [['creator', finance.id, '2.00'], ['lender_employee', lenderEmployee.id, '2.50']]) {
const forged = await repay(staffLender.id, staffLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {});
await exportOrder(forged.id);
const forgedReceipt = await receipt(forged.id, String(Math.round(Number(amount) * 100)), `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount });
assert.equal(forgedReceipt.result.status, 'matched');
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } });
await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required');
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: manager.id } });
assert.equal((await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {})).item.status, 'executed');
}
checks.push('post_payment_still_rejects_non_independent_recorded_approver');
// Pre-payment export still requires the approver to be currently eligible.
const reviewerC = await actor(['manager']);
const prePayment = await repay(staffLender.id, staffLoan.id, '1.00');
await call(reviewerC, 'POST', `/payment-batches/${prePayment.id}/review`, {});
await setStatus(reviewerC, 'archived');
await exportOrder(prePayment.id, 403, 'loan.independent_review_required');
await setStatus(reviewerC, 'active');
await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' });
checks.push('pre_payment_export_keeps_current_approver_eligibility');
// C: a matched receipt does not prove which same-lender, same-amount batch
// the bank paid. Settlement and failure release need an independently
// reviewed attribution; OCR matches and manual receipt reviews cannot bypass it.
const probeLender = await verifiedLender('合成出借人丁', null, '6');
const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入');
const probeBalance = async () => { const b = await call(finance, 'GET', `/company-loans/${probeLoan.id}`); return `${b.outstanding}/${b.reserved}`; };
const attribute = (who, batchId, body, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution`, {
basisKind: 'customer_serial', basisNote: '合成回单客户方流水号与本批次制单流水号一致', exclusionNote: '合成其他候选批次流水号均不一致', ...body }, status, reason);
const reviewAttribution = (who, batchId, id, decision = 'approve', status = 200, reason) => call(who, 'POST',
`/payment-batches/${batchId}/loan-receipt-attribution/${id}/review`, { decision, reason: '合成独立复核归属' }, status, reason);
const detail = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}`)).item ?? (await call(finance, 'GET', `/payment-batches/${batchId}`));
// C1: two same-amount repayments in flight; P's genuine receipt uploaded to Q.
const c1p = await repay(probeLender.id, probeLoan.id, '7.00');
const c1q = await repay(probeLender.id, probeLoan.id, '7.00');
for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
const beforeC1 = await probeBalance();
const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount });
assert.equal(c1.result.status, 'matched');
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal(await probeBalance(), beforeC1, 'C1 wrong receipt must not settle or release');
const qState = (await detail(c1q.id)).loanRepayment.receiptAttribution;
assert.equal(qState.required, true); assert.deepEqual(qState.competitors.map((item) => item.id), [c1p.id]);
// Correct receipt for P still needs attribution because Q is a competitor.
await receipt(c1p.id, '700', 'SYNTHETIC-C1-P-OWN', { payeeAccount: probeLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
await attribute(finance, c1p.id, { basisKind: 'time_alignment' }, 400);
const proposed = await attribute(finance, c1p.id, {});
await attribute(finance, c1p.id, {}, 409, 'loan.receipt_attribution_exists');
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
await reviewAttribution(finance, c1p.id, proposed.id, 'approve', 403);
const dual = await actor(['finance', 'manager']);
await reviewAttribution(dual, c1p.id, proposed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {})).item.status, 'executed');
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
checks.push('same_amount_wrong_batch_receipt_blocked_and_reviewed_attribution_unlocks_settlement');
// A proposer holding manager rights cannot review their own attribution.
const selfLender = await verifiedLender('合成出借人戊', null, '7');
const selfLoan = await confirmedLoan(selfLender.id, '100.00', '合成戊借入');
const s1 = await repay(selfLender.id, selfLoan.id, '3.00'); const s2 = await repay(selfLender.id, selfLoan.id, '3.00');
for (const item of [s1, s2]) { await call(independent, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
await receipt(s1.id, '300', 'SYNTHETIC-SELF-REVIEW', { payeeAccount: selfLender.bankAccount });
const selfProposal = await attribute(dual, s1.id, {});
await reviewAttribution(dual, s1.id, selfProposal.id, 'approve', 403);
await reviewAttribution(manager, s1.id, selfProposal.id, 'reject');
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
// An approval stops covering once a new competitor reaches the bank.
const reproposal = await attribute(finance, s1.id, {});
await reviewAttribution(manager, s1.id, reproposal.id, 'approve');
const s3 = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${s3.id}/review`, {}); await exportOrder(s3.id);
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
const staleState = (await detail(s1.id)).loanRepayment.receiptAttribution.current;
assert.equal(staleState.coversCurrentCompetitors, false); assert.equal(staleState.stale, true);
checks.push('attribution_self_review_rejection_and_new_competitor_invalidate_approval');
// The stale approval must not deadlock the batch: re-registering supersedes it.
const readiness = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}/receipt-readiness`)).item;
assert.deepEqual(await readiness(s1.id), { ready: false, receiptAttributionRequired: true });
const renewed = await attribute(finance, s1.id, {});
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: reproposal.id } })).status, 'superseded');
await reviewAttribution(manager, s1.id, renewed.id, 'approve');
assert.deepEqual(await readiness(s1.id), { ready: true });
assert.equal((await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {})).item.status, 'executed');
checks.push('stale_approval_superseded_by_reregistration_then_settles');
// Swapping the receipt after approval: the old approval cannot unlock the
// new transaction, and a new registration for the new receipt works.
const swapReviewer = await actor(['manager']);
const swapExporter = await actor(['finance', 'manager']);
const swap = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${swap.id}/review`, {});
const swapExport = await app.inject({ method: 'POST', url: `/api/payment-batches/${swap.id}/payment-order`, headers: swapExporter.headers, payload: {} });
assert.equal(swapExport.statusCode, 200, 'exporter with manager role exports');
const firstReceipt = await receipt(swap.id, '300', 'SYNTHETIC-SWAP-FIRST', { payeeAccount: selfLender.bankAccount });
const swapApproved = await attribute(finance, swap.id, {});
await reviewAttribution(swapExporter, swap.id, swapApproved.id, 'approve', 403);
await reviewAttribution(swapReviewer, swap.id, swapApproved.id, 'approve');
await call(finance, 'POST', `/payment-batches/${swap.id}/receipt-attachments/${firstReceipt.attachment.id}/void`, { reason: '合成回单上传错误作废' });
await receipt(swap.id, '300', 'SYNTHETIC-SWAP-SECOND', { payeeAccount: selfLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal((await detail(swap.id)).loanRepayment.receiptAttribution.current.stale, true);
const swapRenewed = await attribute(finance, swap.id, {});
await reviewAttribution(swapReviewer, swap.id, swapRenewed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {})).item.status, 'executed');
checks.push('receipt_swap_invalidates_approval_exporter_cannot_review_and_reregistration_settles');
// A proposal whose receipt was voided can still be rejected (no receipt needed).
const orphanReceipt = await receipt(s2.id, '300', 'SYNTHETIC-ORPHAN', { payeeAccount: selfLender.bankAccount });
const orphan = await attribute(finance, s2.id, {});
await call(finance, 'POST', `/payment-batches/${s2.id}/receipt-attachments/${orphanReceipt.attachment.id}/void`, { reason: '合成回单作废' });
assert.equal((await detail(s2.id)).loanRepayment.receiptAttribution.receiptAvailable, false);
await attribute(finance, s2.id, {}, 409, 'loan.receipt_attribution_unavailable');
await reviewAttribution(swapReviewer, s2.id, orphan.id, 'reject');
checks.push('proposal_without_receipt_can_be_rejected');
// Same transaction, different evidence (re-scanned receipt with the same bank
// reference): the approval is bound to the reviewed evidence and stops unlocking.
const rescan = await repay(selfLender.id, selfLoan.id, '3.00');
await call(independent, 'POST', `/payment-batches/${rescan.id}/review`, {}); await exportOrder(rescan.id);
const rescanFirst = await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
const rescanApproved = await attribute(finance, rescan.id, {});
await reviewAttribution(swapReviewer, rescan.id, rescanApproved.id, 'approve');
await call(finance, 'POST', `/payment-batches/${rescan.id}/receipt-attachments/${rescanFirst.attachment.id}/void`, { reason: '合成重新扫描' });
await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal((await detail(rescan.id)).loanRepayment.receiptAttribution.current.stale, true);
const rescanRenewed = await attribute(finance, rescan.id, {});
const supersedeAudit = await prisma.auditLog.findFirst({ where: { action: 'LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED' }, orderBy: { created_at: 'desc' } });
assert.ok(JSON.stringify(supersedeAudit).includes('receipt_evidence_changed'));
await reviewAttribution(swapReviewer, rescan.id, rescanRenewed.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {})).item.status, 'executed');
checks.push('same_transaction_new_evidence_invalidates_approval_until_rereviewed');
// Settlement's competitor check and a competitor's first receipt upload are
// serialized by the lender lock. Deterministic interleaving in both orders.
const raceLender = await verifiedLender('合成出借人庚', null, '2');
const raceLoan = await confirmedLoan(raceLender.id, '100.00', '合成庚借入');
const uploadOnly = (batchId) => {
const boundary = `synthetic-${randomUUID()}`;
return app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
};
const executeRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
const racePool = new LockPool({ ...pool.options, max: 2 });
const raceWaiters = async () => Number((await racePool.query(
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
async function raceWaiting(count) {
for (let i = 0; i < 300; i++) { if (await raceWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
throw new Error(`expected ${count} requests queued behind the lender lock`);
}
try {
for (const [order, amount, cents] of [['settle_first', '9.00', '900'], ['upload_first', '8.00', '800']]) {
const settling = await repay(raceLender.id, raceLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${settling.id}/review`, {}); await exportOrder(settling.id);
assert.equal((await receipt(settling.id, cents, `SYNTHETIC-RACE-${order}`, { payeeAccount: raceLender.bankAccount })).result.status, 'matched');
const competitor = await repay(raceLender.id, raceLoan.id, amount);
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
const holder = await racePool.connect();
let executed; let uploaded;
try {
await holder.query('BEGIN');
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [raceLender.id]);
if (order === 'settle_first') {
executed = executeRequest(settling.id); await raceWaiting(1);
uploaded = uploadOnly(competitor.id); await raceWaiting(2);
} else {
uploaded = uploadOnly(competitor.id); await raceWaiting(1);
executed = executeRequest(settling.id); await raceWaiting(2);
}
await holder.query('COMMIT');
[executed, uploaded] = [await executed, await uploaded];
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
// Keep the shared OCR queue clean for later scenarios.
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
}
} finally { await racePool.end(); }
checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders');
// C2: old batch confirmed failed and cancelled; its late success receipt
// lands on a new same-amount batch, via OCR and via manual receipt review.
const c2old = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {});
const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id });
await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' });
const c2new = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id);
const beforeC2 = await probeBalance();
const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount });
assert.equal(c2.result.status, 'matched');
await call(finance, 'POST', `/payment-batches/${c2new.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal(await probeBalance(), beforeC2, 'C2 late receipt must not settle the new batch');
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
const beforeManual = await probeBalance();
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
pages: [{ pageNumber: 1, itemId: c2manualBatch.items[0].id, fields: Object.entries({ 小写金额: '6.00', 币别: 'CNY', 付款人账号: payerAccount,
收款人账号: probeLender.bankAccount, 凭证号: 'SYNTHETIC-C2-MANUAL', 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })) }] });
const c2saved = c2proposal.item ?? c2proposal;
await call(manager, 'POST', `${c2path}/${c2saved.id}/review`, { expectedVersion: c2saved.version, decision: 'approve', reason: '合成人工复核' });
await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal(await probeBalance(), beforeManual, 'manual receipt review must not bypass attribution');
// The person who keyed the manual receipt cannot also approve its attribution.
const c2attribution = await attribute(finance, c2manualBatch.id, {});
await reviewAttribution(dual, c2manualBatch.id, c2attribution.id, 'approve', 403);
await reviewAttribution(independent, c2manualBatch.id, c2attribution.id, 'approve');
assert.equal((await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {})).item.status, 'executed');
checks.push('late_receipt_of_cancelled_batch_blocked_via_ocr_and_manual_review');
// A failed receipt cannot release a reservation while competitors exist.
const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00');
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {});
const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failReceipt.attachment.id }, 409, 'loan.receipt_attribution_required');
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe');
checks.push('ambiguous_failed_receipt_cannot_release_reservation');
// No competitor: attribution is neither required nor accepted.
const soloLender = await verifiedLender('合成出借人己', null, '1');
const soloLoan = await confirmedLoan(soloLender.id, '50.00', '合成己借入');
const solo = await repay(soloLender.id, soloLoan.id, '4.00');
await call(manager, 'POST', `/payment-batches/${solo.id}/review`, {}); await exportOrder(solo.id);
await receipt(solo.id, '400', 'SYNTHETIC-SOLO', { payeeAccount: soloLender.bankAccount });
await attribute(finance, solo.id, {}, 409, 'loan.receipt_attribution_not_required');
assert.equal((await call(finance, 'POST', `/payment-batches/${solo.id}/execute`, {})).item.status, 'executed');
checks.push('unique_amount_settles_without_attribution');
// Database guards: reviewed history immutable, reviewer differs from proposer.
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { review_note: '改写' } }));
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { status: 'rejected' } }));
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: reproposal.id }, data: { status: 'approved' } }));
await assert.rejects(prisma.loanReceiptAttribution.delete({ where: { id: proposed.id } }));
checks.push('attribution_history_immutable_in_database');
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
// Attribution UI scenario: a matched receipt on one of two same-amount exported batches.
const uiLender = await verifiedLender('合成浏览器归属出借人', null, '0');
const uiLoan = await confirmedLoan(uiLender.id, '100.00', '合成浏览器归属借入');
const uiExported = async () => { const item = await repay(uiLender.id, uiLoan.id, '5.00');
await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); return item; };
const uiTarget = await uiExported(); await uiExported();
assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched');
const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported,
execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) };
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
};