借款对账 S3:银行终态确认与释放(09-17-company-loan-principal-repayment)

迁移 20261008010000
- 银行终态证据单独成表(不进 OCR、不改付款状态、不计回单),只能作废一次,
  作废即令依赖事实失效;复核人可凭理由查看(审计、SHA-256 校验)
- 每次银行提交一条终态事实:书面终态证据,或流水号等于指令号的严格失败回单;
  同一指令的终态可了结该指令的处理中回单,其他提交不能代替
- 新终态 confirmed_not_paid;释放须清单已复核未变、每次提交有成立的终态事实、
  导出均关联提交、回单均有解释、等待期满、无历史汇总
- 最终取消在同一事务内重验(与新提交、晚到回单串行);两条释放路径都检查等待期
- 防挪用:指令号跨批次唯一(含撤回)、失败回单绑定本批次指令、一笔失败交易
  只支撑一个批次、同一书面证据不能用于两个批次、登记提交即离开“未发送”

验证:借款 HTTP 57 组、浏览器 7 组、77 迁移;server 4472;四轮同模型只读复核
阻断项均已修复,资金守卫变异测试均变红。

注意:界面未完成,不能单独部署;真实回单待验;补证后的恢复缺口尚未设计。
未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-07 16:50:17 +08:00
共同撰写人 Claude Opus 5.5
父节点 db28706177
当前提交 14aee82d5c
共修改 12 个文件,包含 1299 行新增和 81 行删除
+340 -3
查看文件
@@ -247,11 +247,25 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
// S1: failure needs a registered bank submission whose completeness was
// independently attested (registrar/proposer finance, reviewer `by`).
async function attestSubmission(batchId, { by = manager, registrar = finance } = {}) {
// The submission is registered under the instruction reference that the
// batch's current failed slip carries (as finance would read it off the bank).
const { decryptStringOrThrow } = req('./src/services/crypto');
async function slipReference(batchId) {
const attachments = await prisma.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batchId, archived_at: null },
include: { verifications: { orderBy: [{ created_at: 'desc' }, { id: 'desc' }], take: 1 } }, orderBy: { created_at: 'desc' } });
for (const attachment of attachments) {
const verification = attachment.verifications[0];
if (!verification) continue;
const page = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)).pages?.[0];
if (page?.reference) return page.reference;
}
return `SYN-INSTR-${batchId.slice(0, 8)}`;
}
async function attestSubmission(batchId, { by = manager, registrar = finance, ref } = {}) {
const exported = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'desc' } });
const submission = await call(registrar, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, {
...(exported ? { exportId: exported.id, channel: 'online_banking_import' } : { channel: 'manual_entry' }),
submittedAt: new Date().toISOString(), bankInstructionRef: `SYN-INSTR-${batchId.slice(0, 8)}`, note: '合成网银提交登记' });
submittedAt: new Date().toISOString(), bankInstructionRef: ref ?? await slipReference(batchId), note: '合成网银提交登记' });
const attestation = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' });
await call(by, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${attestation.id}/review`, { decision: 'approve', reason: '合成清单复核' });
return { submission, attestation };
@@ -741,7 +755,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-NO-EXPORT', note: '合成网银导入无导出' }, 409, 'loan.submission_export_invalid');
const registrarDual = await actor(['finance', 'manager']);
const firstSubmission = await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-FIRST', note: '合成手工提交登记' });
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYNTHETIC-S1-MISUPLOAD', note: '合成手工提交登记' });
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-MANAGER', note: '合成经理不能登记' }, 403);
// Registered but not yet attested: still refused.
@@ -820,6 +834,329 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
await confirmFailure(item.id, failed.attachment.id, status, reason);
}
checks.push('only_four_failure_wordings_with_complete_single_page_slip_confirm_failure');
// ===== S3: bank terminal facts and "not paid" release =====
config.loanReleaseWaitDays = 0;
const s3Lender = await verifiedLender('合成出借人癸', null, '3', '3579135791357913');
const s3Loan = await confirmedLoan(s3Lender.id, '1000.00', '合成癸借入');
const s3Batch = async (amount) => { const item = await repay(s3Lender.id, s3Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; };
const s3Exports = async (batchId) => prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'asc' } });
const register = (batchId, ref, exportId, who = finance, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, {
...(exportId ? { exportId, channel: 'online_banking_import' } : { channel: 'manual_entry' }),
submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成银行提交登记' }, status, reason);
const attest = async (batchId) => {
const proposal = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' });
await call(manager, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${proposal.id}/review`, { decision: 'approve', reason: '合成清单复核' });
};
// Each synthetic bank letter is a distinct document (unique title).
const bankLetterPdf = async () => { const doc = await PDFDocument.create(); doc.setTitle(`合成银行回复 ${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
async function uploadEvidence(batchId, bytes = null) {
const letter = bytes ?? await bankLetterPdf();
const boundary = `synthetic-${randomUUID()}`;
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), letter, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(response.statusCode, 200, `terminal evidence upload ${response.body.slice(0, 120)}`);
return response.json().data.id;
}
const proposeFact = (batchId, body, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/loan-terminal-facts`, { note: '合成银行终态说明', ...body }, status, reason);
const reviewFact = (batchId, factId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-terminal-facts/${factId}/review`, { decision: 'approve', reason: '合成终态复核' }, status, reason);
const confirmNotPaid = (batchId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-not-paid/review`, {}, status, reason);
const cancelBatch = (batchId, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/cancel`, { reason: '合成确认未付款后释放' }, status, reason);
const s3Available = async () => Number((await call(finance, 'GET', `/company-loans/${s3Loan.id}`)).available);
const s3Slip = (batchId, cents, ref, status, wording) => receipt(batchId, cents, ref, { payeeAccount: s3Lender.bankAccount, transactionStatus: status,
...(wording ? { statusWordings: [wording] } : {}) });
// Two exports, two submissions: each needs its own terminal fact; one
// instruction's terminal state does not end the other.
const multi = await s3Batch('41.00');
await exportOrder(multi.id); await exportOrder(multi.id);
const [mE1, mE2] = await s3Exports(multi.id);
const mS1 = await register(multi.id, 'S3-MULTI-1', mE1.id); const mS2 = await register(multi.id, 'S3-MULTI-2', mE2.id, registrarDual);
const conflictBatch = await s3Batch('42.00');
await register(conflictBatch.id, 's3-multi-1', null, finance, 409, 'loan.instruction_ref_conflict');
await attest(multi.id);
await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete');
const evidence1 = await uploadEvidence(multi.id);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: multi.loanRepaymentId } })).bank_result_state, 'unknown');
assert.equal(await prisma.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: multi.id } }), 0, 'evidence is not a payment receipt');
const fact1 = await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 });
await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 }, 409, 'loan.terminal_fact_exists');
await reviewFact(multi.id, fact1.id, finance, 403);
await reviewFact(multi.id, fact1.id);
await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete');
const mFail = await s3Slip(multi.id, '4100', 'S3-MULTI-2', 'failed');
// A failed slip of a different instruction cannot cover this submission.
await proposeFact(multi.id, { submissionId: mS1.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id }, 409, 'loan.terminal_fact_exists');
const fact2 = await proposeFact(multi.id, { submissionId: mS2.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id });
await reviewFact(multi.id, fact2.id);
await confirmNotPaid(multi.id, registrarDual, 403);
const beforeMulti = await s3Available();
await confirmNotPaid(multi.id);
await cancelBatch(multi.id);
assert.equal(await s3Available(), beforeMulti + 41);
checks.push('s3_each_submission_needs_own_terminal_fact_then_release');
// A failed slip whose reference is another instruction is rejected for this submission.
const wrongRef = await s3Batch('43.00');
await exportOrder(wrongRef.id);
const [wE] = await s3Exports(wrongRef.id);
const wS = await register(wrongRef.id, 'S3-WRONGREF-1', wE.id);
await attest(wrongRef.id);
const wFail = await s3Slip(wrongRef.id, '4300', 'S3-SOMETHING-ELSE', 'failed');
await proposeFact(wrongRef.id, { submissionId: wS.id, kind: 'failure_receipt', receiptAttachmentId: wFail.attachment.id }, 409, 'loan.terminal_fact_invalid');
checks.push('s3_failure_slip_bound_to_submission_instruction_ref');
// Export not linked to any submission blocks release.
const unlinked = await s3Batch('44.00');
await exportOrder(unlinked.id);
const uS = await register(unlinked.id, 'S3-UNLINKED-1', null);
await attest(unlinked.id);
const uEv = await uploadEvidence(unlinked.id);
const uFact = await proposeFact(unlinked.id, { submissionId: uS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: uEv });
await reviewFact(unlinked.id, uFact.id);
await confirmNotPaid(unlinked.id, manager, 409, 'loan.export_unlinked');
checks.push('s3_unlinked_export_blocks_release');
// Pending slip of the same instruction is settled by its terminal fact,
// whether still active or already voided; another instruction cannot.
for (const voidFirst of [false, true]) {
const pend = await s3Batch(voidFirst ? '46.00' : '45.00');
await exportOrder(pend.id); await exportOrder(pend.id);
const [pE1, pE2] = await s3Exports(pend.id);
const ref1 = voidFirst ? 'S3-PEND-V-1' : 'S3-PEND-A-1'; const ref2 = voidFirst ? 'S3-PEND-V-2' : 'S3-PEND-A-2';
const pS1 = await register(pend.id, ref1, pE1.id); const pS2 = await register(pend.id, ref2, pE2.id);
const pending = await s3Slip(pend.id, voidFirst ? '4600' : '4500', ref1, 'pending');
if (voidFirst) await call(finance, 'POST', `/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/void`, { reason: '合成处理中回单作废' });
await attest(pend.id);
const pEv = await uploadEvidence(pend.id);
// Another submission's terminal fact cannot settle this pending slip.
await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
resolvesAttachmentIds: [pending.attachment.id] }, 409, 'loan.terminal_fact_invalid');
const pF1 = await proposeFact(pend.id, { submissionId: pS1.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
resolvesAttachmentIds: [pending.attachment.id] });
await reviewFact(pend.id, pF1.id);
const pF2 = await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: pEv });
await reviewFact(pend.id, pF2.id);
await confirmNotPaid(pend.id);
await cancelBatch(pend.id);
}
// Without a resolving fact the pending slip keeps blocking.
const pendBlocked = await s3Batch('47.00');
await exportOrder(pendBlocked.id);
const [pbE] = await s3Exports(pendBlocked.id);
const pbS = await register(pendBlocked.id, 'S3-PEND-B-1', pbE.id);
await s3Slip(pendBlocked.id, '4700', 'S3-PEND-B-1', 'pending');
await attest(pendBlocked.id);
const pbEv = await uploadEvidence(pendBlocked.id);
const pbF = await proposeFact(pendBlocked.id, { submissionId: pbS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pbEv });
await reviewFact(pendBlocked.id, pbF.id);
await confirmNotPaid(pendBlocked.id, manager, 409, 'loan.receipt_unresolved_for_release');
checks.push('s3_pending_slip_resolved_only_by_same_instruction_terminal_fact');
// Internal wait period.
const waiting = await s3Batch('48.00');
await exportOrder(waiting.id);
const [waE] = await s3Exports(waiting.id);
const waS = await register(waiting.id, 'S3-WAIT-1', waE.id);
await attest(waiting.id);
const waEv = await uploadEvidence(waiting.id);
const waF = await proposeFact(waiting.id, { submissionId: waS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: waEv });
await reviewFact(waiting.id, waF.id);
config.loanReleaseWaitDays = 3;
await confirmNotPaid(waiting.id, manager, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0;
await confirmNotPaid(waiting.id);
// Final release re-checks: evidence voided after confirmation blocks the cancel.
await call(finance, 'POST', `/payment-batches/${waiting.id}/loan-terminal-evidences/${waEv}/void`, { reason: '合成证据作废' });
await cancelBatch(waiting.id, 409, 'loan.terminal_coverage_incomplete');
checks.push('s3_wait_period_and_voided_evidence_blocks_final_release');
// Final release re-checks in the cancel transaction, with deterministic
// interleaving against a late receipt and a new submission (batch lock).
const s3LockPool = new LockPool({ ...pool.options, max: 2 });
const s3Waiters = async () => Number((await s3LockPool.query(
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
const s3Waiting = async (count) => { for (let i = 0; i < 300; i++) { if (await s3Waiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error('not queued'); };
const readyToRelease = async (amount, ref) => {
const item = await s3Batch(amount);
await exportOrder(item.id);
const [e] = await s3Exports(item.id);
const sub = await register(item.id, ref, e.id);
await attest(item.id);
const ev = await uploadEvidence(item.id);
const f = await proposeFact(item.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: ev });
await reviewFact(item.id, f.id);
await confirmNotPaid(item.id);
return item;
};
const lateUpload = (batchId, ref) => {
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="late.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), onePagePdf, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
};
const lateSubmission = (batchId, ref) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-bank-submissions`, headers: finance.headers,
payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成晚到的另一次提交' } });
const cancelRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/cancel`, headers: finance.headers, payload: { reason: '合成并发释放' } });
try {
let n = 50;
for (const [label, makeLate] of [['receipt', lateUpload], ['submission', lateSubmission]]) {
for (const order of ['release_first', 'late_first']) {
n += 1;
const item = await readyToRelease(`${n}.00`, `S3-RACE-${label}-${order}`.toUpperCase());
const holder = await s3LockPool.connect();
let released; let late;
try {
await holder.query('BEGIN');
await holder.query('SELECT id FROM payment_batches WHERE id = $1 FOR UPDATE', [item.id]);
if (order === 'release_first') { released = cancelRequest(item.id); await s3Waiting(1); late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(2); }
else { late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(1); released = cancelRequest(item.id); await s3Waiting(2); }
await holder.query('COMMIT');
[released, late] = [await released, await late];
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (order === 'release_first') {
assert.equal(released.statusCode, 200, `${label} release first`);
assert.equal(late.statusCode, 409, `${label} after release is refused`);
} else {
assert.equal(late.statusCode, 200, `${label} late first`);
assert.equal(released.statusCode, 409, `${label} release after late ${released.body.slice(0, 100)}`);
}
}
}
} finally { await s3LockPool.end(); }
// Drain the OCR queue of the late uploads (recognized as unreadable) so later
// scenarios process their own receipts.
for (let i = 0; i < 10; i++) {
const drained = await runReceiptOcrOnce({ prisma, config, recognize: async () => ({ status: 'needs_review', pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, amount: null, currency: null, payerAccount: null, payeeAccount: null, transactionStatus: null, reference: null, issues: ['amount_unresolved'] }] }) });
if (drained.status === 'idle') break;
}
checks.push('s3_final_release_rechecks_and_serializes_with_late_receipt_and_submission');
// The single-failure path also re-checks at release: a late receipt blocks it.
const failPath = await s3Batch('60.00');
const fpFail = await s3Slip(failPath.id, '6000', 'S3-FAILPATH-1', 'failed');
await attestSubmission(failPath.id);
await call(manager, 'POST', `/payment-batches/${failPath.id}/bank-failure-review`, { attachmentId: fpFail.attachment.id });
await s3Slip(failPath.id, '6000', 'S3-FAILPATH-LATE', 'success');
await cancelBatch(failPath.id, 409, 'loan.bank_state_unsafe');
checks.push('s3_failure_path_rechecks_at_release');
// The single failure path also honours the internal wait after the last export,
// both when confirming and again at the final cancel.
const failWait = await s3Batch('68.00');
await exportOrder(failWait.id);
const fwFail = await s3Slip(failWait.id, '6800', 'S3-FAILWAIT-1', 'failed');
await attestSubmission(failWait.id, { ref: 'S3-FAILWAIT-1' });
config.loanReleaseWaitDays = 3;
await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id }, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0;
await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id });
config.loanReleaseWaitDays = 3;
await cancelBatch(failWait.id, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0;
await cancelBatch(failWait.id);
checks.push('s3_failure_path_honours_wait_period_at_confirm_and_cancel');
// B1: registering a submission ends "never sent"; it cannot be cancelled directly.
const neverSent = await s3Batch('61.00');
await register(neverSent.id, 'S3-NEVERSENT-1', null);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: neverSent.loanRepaymentId } })).bank_result_state, 'unknown');
await cancelBatch(neverSent.id, 409, 'loan.bank_state_unsafe');
await register(neverSent.id, 's3-neversent-1', null, finance, 409, 'loan.instruction_ref_conflict');
checks.push('s3_submission_registration_ends_never_sent_and_refs_unique_within_batch');
// B3: reviewers can view the evidence they approve, with a reason and audit.
const viewEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } });
const viewPath = `/api/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/file`;
assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: manager.headers })).statusCode, 400, 'reason required');
const viewed = await app.inject({ method: 'GET', url: viewPath, headers: { ...manager.headers, 'x-access-reason': encodeURIComponent('合成复核查看证据') } });
assert.equal(viewed.statusCode, 200);
assert.equal(require('node:crypto').createHash('sha256').update(viewed.rawPayload).digest('hex'), viewEvidence.file_sha256, 'evidence bytes served unchanged');
assert.equal(viewed.headers['cache-control'], 'no-store');
assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: { ...ordinary.headers, 'x-access-reason': 'x1x' } })).statusCode, 403);
assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_TERMINAL_EVIDENCE_VIEWED' } }) >= 1);
checks.push('s3_terminal_evidence_viewable_by_reviewer_with_reason_and_audit');
// Non-blocking hardening: no export after not-paid; no void after release.
await call(finance, 'POST', `/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/void`, { reason: '合成释放后作废' }, 409, 'loan.payment_not_ready');
const noReexport = await readyToRelease('62.00', 'S3-NOREEXPORT-1');
await exportOrder(noReexport.id, 409, 'loan.bank_state_unsafe');
await cancelBatch(noReexport.id);
await exportOrder(noReexport.id, 409, 'loan.payment_not_ready');
checks.push('s3_no_reexport_after_conclusion_or_release_and_no_void_after_release');
// B2: with same-amount competitors a failure_receipt terminal fact needs a
// per-submission failure attribution; one failed transaction backs only one batch.
const compA = await s3Batch('63.00'); const compB = await s3Batch('63.00');
for (const item of [compA, compB]) await exportOrder(item.id);
const [cAE] = await s3Exports(compA.id);
const cAS = await register(compA.id, 'S3-COMP-A', cAE.id);
await attest(compA.id);
const cAFail = await s3Slip(compA.id, '6300', 'S3-COMP-A', 'failed');
await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id }, 409, 'loan.receipt_attribution_required');
const perSubmission = await attribute(finance, compA.id, { kind: 'failure', attachmentId: cAFail.attachment.id, submissionId: cAS.id,
basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次登记的指令号', exclusionNote: '合成同额批次未登记该指令号' });
await reviewAttribution(independent, compA.id, perSubmission.id, 'approve');
const cAFact = await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id });
await reviewFact(compA.id, cAFact.id);
await confirmNotPaid(compA.id);
await cancelBatch(compA.id);
// The same failed transaction cannot also release batch B through the single path.
const cBFail = await s3Slip(compB.id, '6300', 'S3-COMP-A', 'failed');
const [cBE] = await s3Exports(compB.id);
await register(compB.id, 'S3-COMP-B', cBE.id); await attest(compB.id);
// Bound to its own instruction, B cannot even attribute A's failed slip; the
// evidence-use registry stays as defence in depth behind this binding.
await attribute(finance, compB.id, { kind: 'failure', attachmentId: cBFail.attachment.id, basisKind: 'bank_instruction_ref',
basisNote: '合成试图复用他批失败回单', exclusionNote: '合成试图复用他批失败回单' }, 409, 'loan.terminal_fact_invalid');
await call(manager, 'POST', `/payment-batches/${compB.id}/bank-failure-review`, { attachmentId: cBFail.attachment.id }, 409, 'loan.terminal_fact_invalid');
assert.equal((await prisma.loanFailureEvidenceUse.findMany({ where: { payment_batch_id: compA.id } })).length, 1, 'A registered its failure evidence use');
checks.push('s3_failure_fact_needs_attribution_with_competitors_and_failure_evidence_backs_one_batch');
// N1: a failed slip of another batch's instruction cannot release this batch
// through the single path, even when that batch only registered a submission.
const p1A = await s3Batch('64.00'); const p1B = await s3Batch('64.00');
await exportOrder(p1A.id);
await register(p1B.id, 'S3-P1-B-REF', null);
const p1Fail = await s3Slip(p1A.id, '6400', 'S3-P1-B-REF', 'failed');
await attestSubmission(p1A.id, { ref: 'S3-P1-A-REF' });
await call(manager, 'POST', `/payment-batches/${p1A.id}/bank-failure-review`, { attachmentId: p1Fail.attachment.id }, 409, 'loan.terminal_fact_invalid');
assert.ok((await detail(p1A.id)).loanRepayment.receiptAttribution.competitors.some((item) => item.id === p1B.id), 'submission-only batch is a competitor');
checks.push('s3_single_path_failure_slip_bound_to_own_instruction_and_submission_counts_as_competitor');
// Batch-scope and submission-scope failure attributions can coexist.
const both = await s3Batch('65.00'); const bothRival = await s3Batch('65.00');
await exportOrder(both.id); await exportOrder(bothRival.id);
const bothFail = await s3Slip(both.id, '6500', 'S3-BOTH-REF', 'failed');
const { submission: bothSub } = await attestSubmission(both.id, { ref: 'S3-BOTH-REF' });
const bothBatchScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, basisKind: 'bank_instruction_ref',
basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' });
await reviewAttribution(independent, both.id, bothBatchScope.id, 'approve');
const bothSubmissionScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, submissionId: bothSub.id,
basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' });
await reviewAttribution(independent, both.id, bothSubmissionScope.id, 'approve');
checks.push('s3_batch_and_submission_scope_failure_attributions_coexist');
// A withdrawn instruction reference cannot be re-registered on another batch,
// and the same bank letter cannot be uploaded to two batches.
const wdA = await s3Batch('66.00'); const wdB = await s3Batch('67.00');
const wdSub = await register(wdA.id, 'S3-WITHDRAWN-REF', null);
await call(finance, 'POST', `/payment-batches/${wdA.id}/loan-bank-submissions/${wdSub.id}/withdraw`, { reason: '合成撤回指令' });
await register(wdB.id, 'S3-WITHDRAWN-REF', null, finance, 409, 'loan.instruction_ref_conflict');
const sharedLetter = await bankLetterPdf();
await uploadEvidence(wdA.id, sharedLetter);
const dupBoundary = `synthetic-${randomUUID()}`;
const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` },
payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) });
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid');
checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch');
// Evidence separation and history.
const sepEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } });
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: sepEvidence.id }, data: { original_name: '改名.pdf' } }));
await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } }));
await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } }));
checks.push('s3_evidence_and_fact_history_immutable');
delete config.loanReleaseWaitDays;
// History guards in the database.
const anyExport = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: twice.id } });
await assert.rejects(prisma.paymentOrderExport.update({ where: { id: anyExport.id }, data: { sequence: 9 } }));