diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index ec8f9a04..8465a4b2 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,3 +1,67 @@ +## 2026-10-07 S3 本地实施记录(用户批准本地实施、针对性验证与只读复核,并要求纳入三条补充要求;未提交、未推送、未部署、未查询生产) + +迁移 `20261008010000_loan_bank_terminal_facts`: +- `loan_bank_terminal_evidences`:银行终态证据(银行书面回复、网银指令查询官方 PDF 等)**单独成表**,与付款回单分开。它不进入 OCR,上传不改变付款状态,不计入回单核验数量和作废回单判定。证据只能作废一次;作废后,依赖它的终态事实在同一事务内被取代。付款回单无法改成这一类型,因此不能借更换类型来解除成功或处理中回单的阻断。 +- `loan_bank_terminal_facts`:每次银行提交至多一条待复核或已通过的终态事实,分两类—— + - `termination_evidence`:终态为已拒绝、已撤销或已失败; + - `failure_receipt`:严格判定的失败回单,且回单流水号必须等于该提交登记的指令号。 + `resolves_attachment_ids` 用于列出由这条终态事实了结的处理中回单。这些回单必须是本批次付款的单页处理中回单,且流水号等于该提交的指令号;其他提交的终态事实不能代替。终态事实只增不改,复核人须独立。 +- 还款状态新增 `confirmed_not_paid`:终态,必须带依据,可取消释放。完整性函数和历史保护函数原样重建,只加入这个状态。 + +规则: +- 银行指令号登记时规范化(NFKC、去空白、大写)保存,且同一指令号不能同时登记在两个借款批次。 +- 释放条件 `assertNotPaidReleasable`,以下全部满足才可释放: + - 无历史汇总; + - 提交清单已复核且至今未变; + - 每次有效提交都有已复核、当前仍成立的终态事实; + - 每条导出记录都被某次提交关联; + - 本批次每份付款回单要么被终态事实覆盖,要么是能证明为明确失败或他笔付款的作废回单; + - 距最后一次导出已满内部等待期(默认 3 天,可配置)。 +- 经独立复核人确认后,状态转为 `confirmed_not_paid`。 +- 内部等待期适用于全部释放路径:确认未付款、单次失败确认,并在最终取消时重验(2026-10-07 复核补:单次失败路径原先漏了这一项)。 +- **最终取消时在同一事务内重验**:`confirmed_not_paid` 重跑上述全部条件,`confirmed_failed` 重跑单次失败路径的全部条件。取消、上传回单、登记提交都要先锁批次,因此三者串行执行。 +- 在失败或未付款结论之后上传的晚到回单,仍会被记录(状态不变),并因此阻断释放;释放之后才出现的新证据,留给晚到结果对账流程处理。 +- 单次失败路径同时补上导出关联:有导出记录时,提交必须关联该导出(`loan.export_unlinked`)。 +- 新增 8 个原因码,三处都已登记;新增接口包括证据的上传与作废、终态事实的登记与复核、确认未付款(复核人操作)。 + +页面:仍未实施,留待 S8。**不得单独部署启用。** + +验证与复核: +- 首轮针对性验证:HTTP 49 组、浏览器 7 组、77 迁移;11 个资金守卫变异均变红;server 4471 等通过。 +- 同模型只读复核:BLOCK,三项(前两项经复核人副本探针实测),均已修复: + - B1 已登记银行提交但状态仍为 `not_sent` 时,可单人直接取消释放:登记提交时同一事务内转为 `unknown`;取消路径的 `not_sent` 分支另加防线,只要存在任何提交(含已撤回)、历史汇总或终态证据就拒绝。 + - B2 `failure_receipt` 终态事实绕过同金额失败归属、未做交易唯一性检查,同一笔失败交易可能分别让两个批次释放: + - 失败回单分支复用单次失败路径的检查(不得被认领为付款、密钥一致); + - 存在同金额候选时,须有已复核、只覆盖该次提交的失败归属。归属新增 `failure_scope`(batch/submission),唯一性改为“批次+类型+覆盖提交”; + - 新增只增不改的 `loan_failure_evidence_uses`:一笔失败交易只能作为一个批次的依据,单次失败路径和终态事实路径都在此登记,复用返回 `loan.failure_evidence_reused`。 + - B3 终态证据无法查看:新增 `GET …/loan-terminal-evidences/:id/file`,财务或合格的独立复核人可读,须填写理由并写审计,返回前校验 SHA-256,不缓存。 +- 同轮非阻断修复: + - 结论已定或批次已释放后,导出、执行由 500 改为明确的 409; + - 释放后不得作废证据; + - 同一批次内指令号也须唯一。 +- 第二轮同模型只读复核:BLOCK 一项(副本探针实测),B1、B3 修复成立。 + - **N1**:单次失败路径不比对失败回单流水号与本批次指令号;只登记了提交的批次也不算同额候选,于是 A 可凭 B 指令的失败回单确认失败并释放。已修: + - 单次失败路径(确认与最终取消均调用 `assertFailureFacts`)及批次级失败归属,都要求失败回单流水号等于唯一已确认提交的指令号(指令号跨批次唯一); + - 同额候选新增“存在任何提交登记(含撤回)”。 + 由于有了指令号绑定,跨批次复用失败回单在归属阶段就被拦下,`loan_failure_evidence_uses` 登记表退为纵深防御(测试断言 A 已登记用量)。 + - 同轮修复的非阻断项: + - 归属唯一索引与查重加入 `failure_scope`,批次级与提交级可并存,并加 CHECK:提交级必须是失败类、只覆盖一次提交; + - 单次失败路径按覆盖提交精确查找批次级归属; + - uses 表纳入回单密钥一致性检查; + - 证据查看理由改由请求头 `x-access-reason`(URL 编码)传递,不进入访问日志。 + - 新增反例:其他批次指令的失败回单走单次路径被拒,且“只登记提交”的批次算作候选;批次级与提交级失败归属并存。 +- 第三轮同模型只读复核(N1 修复):PASS_WITH_NOTES,无阻断;四条路径(单次失败、批次级与提交级失败归属、failure_receipt 终态事实)绑定一致。按备注补两处纵深: + - 指令号跨批次唯一改为不论状态:撤回后也不能在其他批次重新登记; + - 同一份银行书面证据(按 SHA-256)不能上传到两个批次。 + + 记录: + - 候选范围扩大(只登记提交也算)的代价是同额历史批次让后续批次持续需要归属确认,方向安全,不放宽; + - **回单“流水号/凭证号”与网银指令号是否同一编号、两者如何对应,须用真实建行回单在 S0 核实**。若不同,受影响的是需要绑定的路径——失败回单(单次失败路径、失败归属、`failure_receipt` 终态事实)以及处理中回单的了结,这些路径会被阻断(安全但不可用);只凭银行书面终态证据(`termination_evidence`)、本批次又没有需要了结的处理中回单时,不要求两个编号相等,不受影响。归属不能靠财务照抄回单号来证明;绑定只是机器校验,实际证明仍依赖银行证据、同额候选归属和独立复核。 +- 部署前注意(存量):生产若已有在 S1 之前确认失败、尚未取消的借款批次,按新规则取消时会重跑 S1 条件(单次已复核提交、指令号绑定等),可能需要补登提交并复核;生产是否存在此类数据待 S0 核实。 +- 记录在案(未改): + - `confirmed_not_paid` 之后若证据作废、出现晚到回单或新提交,批次保持占用(方向安全)。其中“钱确实没付、只需补证后重新复核”的情况,会被“只有 `unknown` 状态才能登记终态事实”拦住;S6 只处理晚到成功,覆盖不了这种情况。**这是尚未设计的恢复缺口**,需另行设计恢复路径,不由 S6 负责; + - 等待期默认 3 天,可通过配置 `loanReleaseWaitDays` 调整,尚未接入环境变量;只有手工提交、没有任何导出时,等待期不生效(按设计“距最后一次导出”计算)。 + ## 2026-10-07 S1、S2 本地实施记录(用户 2026-10-06 批准本地实施、针对性验证与只读复核;未提交、未推送、未部署;未做 S0 生产查询) S1(迁移 `20261007010000_loan_bank_submissions`): diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index 055f68d4..42647ac5 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -57,7 +57,7 @@ - [ ] AC8:迁移与回退影响、独立只读复核、恢复风险处置和发布前条件完整;本地合成通过不替代真实银行接受性或部署,真实录入/付款另批。 - [ ] AC9(对账,design 3.1/3.3):制单导出与银行提交尝试分别落表并关联;提交清单须经独立复核;确认失败只计未作废附件,但作废的成功或处理中回单仍按事实阻断;确认失败按批次冻结快照核对,出借员工离职不阻断;一条指令的终态不能推定其他提交已结束。 - [ ] AC10(失败归属,3.1):存在同金额候选时,确认失败须有已复核、写明覆盖哪些指令的失败归属;未确认时保持占用。 -- [ ] AC11(银行终态释放,3.2):每次已登记提交均有能唯一定位指令、经独立复核的终态证据,且无未解决的成功或处理中事实,才可转 `confirmed_not_paid` 并释放;对账单、截图、时间依据与等待期均不能单独放行;释放后禁止再导出。 +- [ ] AC11(银行终态释放,3.2;2026-10-07 补充:同一指令的终态事实可了结该指令此前的处理中回单,其他提交不能代替;最终取消释放的事务须重验全部条件,并与新提交、晚到回单串行;终态证据与付款回单分开存放,作废或被替换后不能继续作为依据):每次已登记提交均有能唯一定位指令、经独立复核的终态证据,且无未解决的成功或处理中事实,才可转 `confirmed_not_paid` 并释放;对账单、截图、时间依据与等待期均不能单独放行;释放后禁止再导出。 - [ ] AC12(人工确认未提交,3.2a,用户 2026-10-06 确认支持):全部证据加两名不同的独立复核人,转 `confirmed_not_submitted`,记录与页面标为人工确认;人员不足或材料缺失时继续占用。 - [ ] AC13(冻结,3.5):冻结按原因记录;拦截新申请及其他在途申请的复核与导出,不拦事实登记;误报经独立复核关闭不补记;证据不确定继续冻结;全部原因关闭才解冻。 - [ ] AC14(晚到成功,3.4):只增不改并标记原结论被推翻;按冻结快照与历史审批核对;覆盖已释放与已确认失败未释放;逐笔先处理占用冲突再按差额结清,余额列待追回;补记不生成可导出指令,整笔超额也完整记录;同一交易不能两次使用。 diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index 7fded9c1..c0efbbbd 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -29,6 +29,15 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。", "loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。", "loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。", + "loan.terminal_fact_invalid": "银行终态事实与提交、证据或回单不一致,或证据已作废、已变化,须重新登记。", + "loan.terminal_coverage_incomplete": "尚有银行提交没有经复核的终态事实,不能确认未付款或释放额度。", + "loan.export_unlinked": "有导出记录没有关联到已登记的银行提交,须先核实该次导出是否提交银行。", + "loan.receipt_unresolved_for_release": "本批次有银行回单尚未被终态事实覆盖,可能显示付款成功或处理中,不能释放额度。", + "loan.release_wait_period": "距最后一次导出未满内部等待期,暂不能确认未付款或释放额度。", + "loan.instruction_ref_conflict": "该银行指令号已登记在其他还款批次,请核对后重新登记。", + "loan.terminal_fact_exists": "该银行提交已有待复核或已通过的终态事实,请勿重复登记。", + "loan.terminal_evidence_invalid": "终态证据文件不符合要求,或已作废、不属于本批次。", + "loan.failure_evidence_reused": "该笔银行失败交易已作为其他还款批次的依据,不能重复使用。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index 96dec04e..fdb785fe 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -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 } })); diff --git a/server/prisma/migrations/20261008010000_loan_bank_terminal_facts/migration.sql b/server/prisma/migrations/20261008010000_loan_bank_terminal_facts/migration.sql new file mode 100644 index 00000000..02f2d1ee --- /dev/null +++ b/server/prisma/migrations/20261008010000_loan_bank_terminal_facts/migration.sql @@ -0,0 +1,244 @@ +-- S3 of the loan bank-result reconciliation design (design.md 3.2): a +-- repayment is released as "not paid" only when every reviewed bank submission +-- has an independently reviewed terminal fact bound to its instruction reference. + +-- Bank termination evidence (bank letters, official instruction-query PDFs) is +-- stored apart from payment receipts: never OCR'd, never counted as a receipt, +-- never changes the payment state. It can only be voided once. +CREATE TABLE "loan_bank_terminal_evidences" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "storage_path" TEXT NOT NULL, + "original_name" VARCHAR(255) NOT NULL, + "mime_type" VARCHAR(100) NOT NULL, + "file_size" INTEGER NOT NULL, + "file_sha256" VARCHAR(64) NOT NULL, + "uploaded_by" UUID NOT NULL, + "uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "voided_by" UUID, + "voided_at" TIMESTAMP(3), + "void_reason" VARCHAR(500), + + CONSTRAINT "loan_bank_terminal_evidences_pkey" PRIMARY KEY ("id"), + CONSTRAINT "loan_bank_terminal_evidences_void_check" CHECK ( + ("voided_at" IS NULL AND "voided_by" IS NULL AND "void_reason" IS NULL) + OR ("voided_at" IS NOT NULL AND "voided_by" IS NOT NULL AND char_length(btrim("void_reason")) >= 2)) +); +CREATE INDEX "loan_bank_terminal_evidences_batch_idx" ON "loan_bank_terminal_evidences"("payment_batch_id"); +CREATE INDEX "loan_bank_terminal_evidences_sha_idx" ON "loan_bank_terminal_evidences"("file_sha256"); + +-- One terminal fact per bank submission (proposed/approved at a time). +-- kind failure_receipt: a strictly clear failed slip whose reference equals the +-- submission's instruction reference. kind termination_evidence: bank evidence +-- naming that instruction and its terminal status. resolves_attachment_ids lists +-- pending slips of the same instruction that this terminal fact settles. +CREATE TABLE "loan_bank_terminal_facts" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "submission_id" UUID NOT NULL, + "kind" VARCHAR(32) NOT NULL, + "terminal_status" VARCHAR(16) NOT NULL, + "evidence_id" UUID, + "receipt_attachment_id" UUID, + "verification_id" UUID, + "resolves_attachment_ids" JSONB NOT NULL DEFAULT '[]', + "note" VARCHAR(1000) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'proposed', + "proposed_by" UUID NOT NULL, + "proposed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewed_by" UUID, + "reviewed_at" TIMESTAMP(3), + "review_note" VARCHAR(500), + "superseded_at" TIMESTAMP(3), + + CONSTRAINT "loan_bank_terminal_facts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "loan_bank_terminal_facts_kind_check" CHECK ( + ("kind" = 'termination_evidence' AND "evidence_id" IS NOT NULL AND "receipt_attachment_id" IS NULL AND "verification_id" IS NULL + AND "terminal_status" IN ('rejected', 'revoked', 'failed')) + OR ("kind" = 'failure_receipt' AND "evidence_id" IS NULL AND "receipt_attachment_id" IS NOT NULL AND "verification_id" IS NOT NULL + AND "terminal_status" = 'failed')), + CONSTRAINT "loan_bank_terminal_facts_resolves_check" CHECK (jsonb_typeof("resolves_attachment_ids") = 'array'), + CONSTRAINT "loan_bank_terminal_facts_note_check" CHECK (char_length(btrim("note")) >= 5), + CONSTRAINT "loan_bank_terminal_facts_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')), + CONSTRAINT "loan_bank_terminal_facts_review_check" CHECK ( + ("status" = 'proposed' AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL) + OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL) + OR "status" = 'superseded'), + CONSTRAINT "loan_bank_terminal_facts_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)), + CONSTRAINT "loan_bank_terminal_facts_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by") +); +CREATE UNIQUE INDEX "loan_bank_terminal_facts_one_active_per_submission" + ON "loan_bank_terminal_facts"("submission_id") WHERE "status" IN ('proposed', 'approved'); +CREATE INDEX "loan_bank_terminal_facts_batch_idx" ON "loan_bank_terminal_facts"("payment_batch_id", "status"); + +ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_submission_id_fkey" FOREIGN KEY ("submission_id") REFERENCES "loan_bank_submissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_evidence_id_fkey" FOREIGN KEY ("evidence_id") REFERENCES "loan_bank_terminal_evidences"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_receipt_attachment_id_fkey" FOREIGN KEY ("receipt_attachment_id") REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_verification_id_fkey" FOREIGN KEY ("verification_id") REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE FUNCTION guard_loan_terminal_evidence_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal evidence history is immutable'; END IF; + IF OLD."voided_at" IS NOT NULL OR NEW."voided_at" IS NULL + OR (NEW."payment_batch_id", NEW."storage_path", NEW."original_name", NEW."mime_type", NEW."file_size", NEW."file_sha256", NEW."uploaded_by", NEW."uploaded_at") + IS DISTINCT FROM (OLD."payment_batch_id", OLD."storage_path", OLD."original_name", OLD."mime_type", OLD."file_size", OLD."file_sha256", OLD."uploaded_by", OLD."uploaded_at") THEN + RAISE EXCEPTION 'loan terminal evidence can only be voided once'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER loan_terminal_evidence_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_evidences" + FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_evidence_history(); + +CREATE FUNCTION guard_loan_terminal_fact_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal fact history is immutable'; END IF; + IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan terminal fact is immutable'; END IF; + IF (NEW."payment_batch_id", NEW."submission_id", NEW."kind", NEW."terminal_status", NEW."evidence_id", NEW."receipt_attachment_id", + NEW."verification_id", NEW."resolves_attachment_ids", NEW."note", NEW."proposed_by", NEW."proposed_at") + IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_id", OLD."kind", OLD."terminal_status", OLD."evidence_id", OLD."receipt_attachment_id", + OLD."verification_id", OLD."resolves_attachment_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN + RAISE EXCEPTION 'loan terminal fact proposal is immutable'; + END IF; + IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' + OR (NEW."reviewed_by", NEW."reviewed_at", NEW."review_note") IS DISTINCT FROM (OLD."reviewed_by", OLD."reviewed_at", OLD."review_note")) THEN + RAISE EXCEPTION 'approved loan terminal fact can only be superseded'; + END IF; + IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded') + AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN + RAISE EXCEPTION 'unreviewed loan terminal fact cannot carry review fields'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER loan_terminal_fact_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_facts" + FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_fact_history(); + +-- New terminal repayment state confirmed_not_paid: like confirmed_failed it is +-- terminal, needs a reference and allows the batch to be cancelled. +ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check; +ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK ( + amount > 0 AND currency_code = 'CNY' AND version > 0 + AND lender_identity_version > 0 AND lender_account_version > 0 + AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_success') + AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL) + AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL) + OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL)) +); + +-- Integrity and history functions re-created with confirmed_not_paid as a +-- cancellable terminal state (otherwise identical to 20260917010000). +CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$ +DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid; +BEGIN + SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid; + IF NOT FOUND THEN RETURN; END IF; + SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id; + IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF; + SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id; + IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id + OR b.default_payment_channel IS DISTINCT FROM 'bank' + OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1 + OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id + AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code) + THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF; + IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount + OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id + WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id + OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version)) + THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF; + IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <> + CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END) + OR (b.status='executed' AND r.bank_result_state<>'confirmed_success') + OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid')) + THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF; + FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP + PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE; + IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations + WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) > + (SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid) + THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF; + END LOOP; +END $$; + +CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_TABLE_NAME='loan_repayment_allocations' THEN + IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF; + IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM + (OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version) + OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD) + THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF; + ELSE + IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF; + IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount, + NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key) + IS DISTINCT FROM + (OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount, + OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key) + OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_success') AND + (NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref)) + OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent') + OR (OLD.accounting_handoff_at IS NOT NULL AND + (NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM + (OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref)) + THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF; + END IF; + RETURN NEW; +END $$; + +-- One failed bank transaction can back the release of only one batch, whichever +-- path relied on it (single-submission confirmation or a per-submission terminal +-- fact). Append-only registry keyed by the transaction fingerprint. +CREATE TABLE "loan_failure_evidence_uses" ( + "fingerprint" VARCHAR(64) NOT NULL, + "key_id" VARCHAR(64) NOT NULL, + "payment_batch_id" UUID NOT NULL, + "source" VARCHAR(32) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "loan_failure_evidence_uses_pkey" PRIMARY KEY ("fingerprint"), + CONSTRAINT "loan_failure_evidence_uses_source_check" CHECK ("source" IN ('failure_confirmation', 'terminal_fact')) +); +ALTER TABLE "loan_failure_evidence_uses" ADD CONSTRAINT "loan_failure_evidence_uses_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +CREATE TRIGGER loan_failure_evidence_uses_append_only BEFORE UPDATE OR DELETE ON "loan_failure_evidence_uses" + FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only(); + +-- Failure attributions now state their scope: the single-submission batch path +-- (S2) or one named submission bound by instruction reference (S3). +ALTER TABLE "loan_receipt_attributions" ADD COLUMN "failure_scope" VARCHAR(16) NOT NULL DEFAULT 'batch'; +ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_failure_scope_check" CHECK ("failure_scope" IN ('batch', 'submission')); +CREATE OR REPLACE FUNCTION guard_loan_receipt_attribution_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan receipt attribution history is immutable'; END IF; + IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan receipt attribution is immutable'; END IF; + IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' OR NEW."reviewed_by" IS DISTINCT FROM OLD."reviewed_by" + OR NEW."reviewed_at" IS DISTINCT FROM OLD."reviewed_at" OR NEW."review_note" IS DISTINCT FROM OLD."review_note") THEN + RAISE EXCEPTION 'approved loan receipt attribution can only be superseded'; + END IF; + IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded') + AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN + RAISE EXCEPTION 'unreviewed loan receipt attribution cannot carry review fields'; + END IF; + IF NEW."payment_batch_id" <> OLD."payment_batch_id" OR NEW."fingerprint" <> OLD."fingerprint" OR NEW."key_id" <> OLD."key_id" + OR NEW."verification_ids" <> OLD."verification_ids" OR NEW."manual_proposal_ids" <> OLD."manual_proposal_ids" + OR NEW."competitor_batch_ids" <> OLD."competitor_batch_ids" OR NEW."basis_kind" <> OLD."basis_kind" + OR NEW."basis_note" <> OLD."basis_note" OR NEW."exclusion_note" <> OLD."exclusion_note" + OR NEW."proposed_by" <> OLD."proposed_by" OR NEW."proposed_at" <> OLD."proposed_at" + OR NEW."kind" <> OLD."kind" OR NEW."covered_submission_ids" <> OLD."covered_submission_ids" + OR NEW."failure_scope" <> OLD."failure_scope" THEN + RAISE EXCEPTION 'loan receipt attribution proposal is immutable'; + END IF; + RETURN NEW; +END $$; + +-- Per-submission failure attributions: at most one open/approved per batch, +-- kind and covered submissions (settlement keeps covered = [] → one per batch). +DROP INDEX "loan_receipt_attributions_one_active_per_batch_kind"; +CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_scope" + ON "loan_receipt_attributions"("payment_batch_id", "kind", "failure_scope", "covered_submission_ids") WHERE "status" IN ('proposed', 'approved'); +ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_submission_scope_check" CHECK ( + "failure_scope" <> 'submission' OR ("kind" = 'failure' AND jsonb_array_length("covered_submission_ids") = 1)); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 543246a9..1d8710bf 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -741,6 +741,9 @@ model Employee { loan_bank_submissions_registered LoanBankSubmission[] loan_submission_attestations_proposed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationProposer") loan_submission_attestations_reviewed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationReviewer") + loan_terminal_evidences_uploaded LoanBankTerminalEvidence[] + loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer") + loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer") @@map("employees") } @@ -1985,6 +1988,9 @@ model PaymentBatch { loan_legacy_export_summary LoanLegacyExportSummary? loan_bank_submissions LoanBankSubmission[] loan_bank_submission_attestations LoanBankSubmissionAttestation[] + loan_terminal_evidences LoanBankTerminalEvidence[] + loan_terminal_facts LoanBankTerminalFact[] + loan_failure_evidence_uses LoanFailureEvidenceUse[] @@map("payment_batches") } @@ -2043,6 +2049,7 @@ model PaymentBatchReceiptAttachment { receipt_ocr_state String @default("legacy") @db.VarChar(32) receipt_ocr_started_at DateTime? + loan_terminal_facts LoanBankTerminalFact[] @@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created") @@index([uploaded_by], map: "idx_payment_batch_receipts_uploader") @@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256") @@ -2066,6 +2073,7 @@ model PaymentReceiptVerification { claims PaymentReceiptClaim[] manual_proposals PaymentReceiptManualProposal[] + loan_terminal_facts LoanBankTerminalFact[] @@index([attachment_id, created_at]) @@map("payment_receipt_verifications") } @@ -4004,6 +4012,7 @@ model LoanReceiptAttribution { manual_proposal_ids Json kind String @default("settlement") @db.VarChar(16) covered_submission_ids Json @default("[]") + failure_scope String @default("batch") @db.VarChar(16) competitor_batch_ids Json basis_kind String @db.VarChar(32) basis_note String @db.VarChar(1000) @@ -4076,6 +4085,8 @@ model LoanBankSubmission { export PaymentOrderExport? @relation(fields: [export_id], references: [id], onDelete: Restrict) registrar Employee @relation(fields: [registered_by], references: [id], onDelete: Restrict) + terminal_facts LoanBankTerminalFact[] + @@index([payment_batch_id, status], map: "loan_bank_submissions_batch_idx") @@map("loan_bank_submissions") } @@ -4101,3 +4112,71 @@ model LoanBankSubmissionAttestation { @@map("loan_bank_submission_attestations") } + +// Loan bank-result reconciliation S3 (migration 20261008010000_loan_bank_terminal_facts). +// Evidence is stored apart from payment receipts; history triggers live in the migration. +model LoanBankTerminalEvidence { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + storage_path String + original_name String @db.VarChar(255) + mime_type String @db.VarChar(100) + file_size Int + file_sha256 String @db.VarChar(64) + uploaded_by String @db.Uuid + uploaded_at DateTime @default(now()) + voided_by String? @db.Uuid + voided_at DateTime? + void_reason String? @db.VarChar(500) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict) + facts LoanBankTerminalFact[] + + @@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx") + @@map("loan_bank_terminal_evidences") +} + +model LoanBankTerminalFact { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + submission_id String @db.Uuid + kind String @db.VarChar(32) + terminal_status String @db.VarChar(16) + evidence_id String? @db.Uuid + receipt_attachment_id String? @db.Uuid + verification_id String? @db.Uuid + resolves_attachment_ids Json @default("[]") + note String @db.VarChar(1000) + status String @default("proposed") @db.VarChar(16) + proposed_by String @db.Uuid + proposed_at DateTime @default(now()) + reviewed_by String? @db.Uuid + reviewed_at DateTime? + review_note String? @db.VarChar(500) + superseded_at DateTime? + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + submission LoanBankSubmission @relation(fields: [submission_id], references: [id], onDelete: Restrict) + evidence LoanBankTerminalEvidence? @relation(fields: [evidence_id], references: [id], onDelete: Restrict) + receipt_attachment PaymentBatchReceiptAttachment? @relation(fields: [receipt_attachment_id], references: [id], onDelete: Restrict) + verification PaymentReceiptVerification? @relation(fields: [verification_id], references: [id], onDelete: Restrict) + proposer Employee @relation("LoanTerminalFactProposer", fields: [proposed_by], references: [id], onDelete: Restrict) + reviewer Employee? @relation("LoanTerminalFactReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict) + + @@index([payment_batch_id, status], map: "loan_bank_terminal_facts_batch_idx") + @@map("loan_bank_terminal_facts") +} + +// One failed bank transaction backs the release of at most one batch. +model LoanFailureEvidenceUse { + fingerprint String @id @db.VarChar(64) + key_id String @db.VarChar(64) + payment_batch_id String @db.Uuid + source String @db.VarChar(32) + created_at DateTime @default(now()) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + + @@map("loan_failure_evidence_uses") +} diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 0b9d8750..c30cf4e7 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -29,6 +29,15 @@ const ERROR_REASONS = [ {"code":"loan.voided_receipt_unresolved","domain":"loan","audience":"user","defaultMessage":"已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。","recovery":"follow_guidance"}, {"code":"loan.submission_export_invalid","domain":"loan","audience":"user","defaultMessage":"所选导出记录不属于本批次,或网银导入的提交未关联导出记录。","recovery":"follow_guidance"}, {"code":"loan.submission_attestation_exists","domain":"loan","audience":"user","defaultMessage":"本批次已有待复核或已通过的提交清单确认,请勿重复提交。","recovery":"follow_guidance"}, + {"code":"loan.terminal_fact_invalid","domain":"loan","audience":"user","defaultMessage":"银行终态事实与提交、证据或回单不一致,或证据已作废、已变化,须重新登记。","recovery":"follow_guidance"}, + {"code":"loan.terminal_coverage_incomplete","domain":"loan","audience":"user","defaultMessage":"尚有银行提交没有经复核的终态事实,不能确认未付款或释放额度。","recovery":"follow_guidance"}, + {"code":"loan.export_unlinked","domain":"loan","audience":"user","defaultMessage":"有导出记录没有关联到已登记的银行提交,须先核实该次导出是否提交银行。","recovery":"follow_guidance"}, + {"code":"loan.receipt_unresolved_for_release","domain":"loan","audience":"user","defaultMessage":"本批次有银行回单尚未被终态事实覆盖,可能显示付款成功或处理中,不能释放额度。","recovery":"follow_guidance"}, + {"code":"loan.release_wait_period","domain":"loan","audience":"user","defaultMessage":"距最后一次导出未满内部等待期,暂不能确认未付款或释放额度。","recovery":"follow_guidance"}, + {"code":"loan.instruction_ref_conflict","domain":"loan","audience":"user","defaultMessage":"该银行指令号已登记在其他还款批次,请核对后重新登记。","recovery":"follow_guidance"}, + {"code":"loan.terminal_fact_exists","domain":"loan","audience":"user","defaultMessage":"该银行提交已有待复核或已通过的终态事实,请勿重复登记。","recovery":"follow_guidance"}, + {"code":"loan.terminal_evidence_invalid","domain":"loan","audience":"user","defaultMessage":"终态证据文件不符合要求,或已作废、不属于本批次。","recovery":"follow_guidance"}, + {"code":"loan.failure_evidence_reused","domain":"loan","audience":"user","defaultMessage":"该笔银行失败交易已作为其他还款批次的依据,不能重复使用。","recovery":"follow_guidance"}, {"code":"config.enabled_query_invalid","domain":"config","audience":"user","defaultMessage":"启用状态筛选无效,请重新选择后查询。","recovery":"follow_guidance"}, {"code":"config.document_type_invalid","domain":"config","audience":"user","defaultMessage":"单据类型标识不符合要求,请重新选择单据类型。","recovery":"follow_guidance"}, {"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"}, diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 2773a303..c02f27d1 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -6,6 +6,8 @@ const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRe proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments"); const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions"); +const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid, + terminalState } = require("../services/loanBankTerminalFacts"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { ERROR_REASONS } = require("../constants/errorReasons"); const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification"); @@ -1051,7 +1053,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = { ...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => { const repayment = await readLoanRepayment(tx, batch); return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config), - bankSubmissions: await bankSubmissionState(tx, batch) }; + bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch) }; })() } : {}), ...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true, @@ -1497,7 +1499,7 @@ async function paymentBatchesRoutes(fastify) { allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100), }).strict(); const ATTRIBUTION_PROPOSAL_SCHEMA = z.object({ - kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: UUID_SCHEMA.optional(), + kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: UUID_SCHEMA.optional(), submissionId: UUID_SCHEMA.optional(), basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved", "bank_instruction_ref"]), basisNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000), }).strict(); @@ -1550,6 +1552,90 @@ async function paymentBatchesRoutes(fastify) { const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body)); return sendData(reply, request, { id: result.id, status: result.status }); }); + fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => { + const files = []; + try { + for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: 10 * 1024 * 1024 } })) { + if (part.type !== "file" || part.fieldname !== "file") throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400); + const buffer = await part.toBuffer(); + files.push(await validateAndNormalizePurchaseAttachment({ buffer, fileName: part.filename, declaredMimeType: part.mimetype, + normalizeImages: true, maxBytes: 10 * 1024 * 1024 })); + } + } catch (error) { + if (error instanceof PurchaseAttachmentPolicyError || String(error.code ?? "").startsWith("FST_")) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400); + throw error; + } + if (files.length !== 1) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400); + const [file] = files; + const storagePath = await writeBuffer({ config: fastify.config, key: `loan-terminal-evidence/${parseId(request.params.id)}/${crypto.randomUUID()}${file.extension}`, + buffer: file.buffer, contentType: file.mimeType }); + try { + const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath })); + return sendData(reply, request, { id: result.id }); + } catch (error) { + try { await deleteObject({ config: fastify.config, storagePath }); } catch { request.log.error("loan terminal evidence cleanup failed"); } + throw error; + } + }); + // Reviewers must see the evidence they approve: finance or an eligible + // independent reviewer (preHandler), with a recorded reason and digest check. + fastify.get("/payment-batches/:id/loan-terminal-evidences/:evidenceId/file", { preHandler: authenticateBatch }, async (request, reply) => { + const batchId = parseId(request.params.id); + const evidenceId = parseId(request.params.evidenceId); + // The reason travels in a header (URL-encoded) so it stays out of access logs. + let headerReason = ""; + try { headerReason = decodeURIComponent(String(request.headers["x-access-reason"] ?? "")); } catch { headerReason = ""; } + const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), { reason: headerReason }); + const evidence = await fastify.prisma.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } }); + if (!evidence || evidence.payment_batch_id !== batchId) throw buildNotFoundError(); + let buffer; + try { + const opened = await openReadStream({ config: fastify.config, storagePath: evidence.storage_path }); + const chunks = []; for await (const chunk of opened.stream) chunks.push(chunk); buffer = Buffer.concat(chunks); + } catch (error) { + if (error instanceof StorageNotFoundError) throw buildNotFoundError(); + throw error; + } + if (crypto.createHash("sha256").update(buffer).digest("hex") !== evidence.file_sha256) { + throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409); + } + await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LOAN_TERMINAL_EVIDENCE_VIEWED", + targetType: "payment_batch", targetId: batchId, extra: { evidenceId, reason, voided: Boolean(evidence.voided_at) } }) }); + reply.header("content-type", evidence.mime_type); + reply.header("content-disposition", buildAttachmentContentDisposition(evidence.original_name)); + reply.header("cache-control", "no-store"); + reply.header("x-content-type-options", "nosniff"); + return reply.send(buffer); + }); + fastify.post("/payment-batches/:id/loan-terminal-evidences/:evidenceId/void", { preHandler: authenticateBatch }, async (request, reply) => { + const evidenceId = parseId(request.params.evidenceId); + const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), request.body); + const result = await attributionTransaction(request, (tx, batch) => voidTerminalEvidence(tx, fastify.config, batch, request, evidenceId, reason)); + return sendData(reply, request, { id: result.id }); + }); + const TERMINAL_FACT_SCHEMA = z.object({ + submissionId: UUID_SCHEMA, kind: z.enum(["termination_evidence", "failure_receipt"]), + terminalStatus: z.enum(["rejected", "revoked", "failed"]).optional(), evidenceId: UUID_SCHEMA.optional(), + receiptAttachmentId: UUID_SCHEMA.optional(), resolvesAttachmentIds: z.array(UUID_SCHEMA).max(20).optional(), + note: z.string().trim().min(5).max(1000), + }).strict(); + fastify.post("/payment-batches/:id/loan-terminal-facts", { preHandler: authenticateBatch }, async (request, reply) => { + const body = parseBody(TERMINAL_FACT_SCHEMA, request.body); + const result = await attributionTransaction(request, (tx, batch) => proposeTerminalFact(tx, fastify.config, batch, request, body)); + return sendData(reply, request, { id: result.id, status: result.status }); + }); + fastify.post("/payment-batches/:id/loan-terminal-facts/:factId/review", { preHandler: authenticateBatch }, async (request, reply) => { + const factId = parseId(request.params.factId); + const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body); + const result = await attributionTransaction(request, (tx, batch) => reviewTerminalFact(tx, fastify.config, batch, request, factId, body)); + return sendData(reply, request, { id: result.id, status: result.status }); + }); + // Reviewer action ("/review" suffix routes through the reviewer preHandler branch). + fastify.post("/payment-batches/:id/loan-not-paid/review", { preHandler: authenticateBatch }, async (request, reply) => { + parseBody(z.object({}).strict(), request.body ?? {}); + await attributionTransaction(request, (tx, batch) => confirmNotPaid(tx, fastify.config, batch, request)); + return sendData(reply, request, { status: "confirmed_not_paid" }); + }); fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => { const id = parseId(request.params.id); const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body); @@ -1870,8 +1956,10 @@ async function paymentBatchesRoutes(fastify) { const batch = await lockBatchWithItems(tx, batchId); await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch); if (batch.entity_type === "loan_repayment") { + if (!["reviewed", "failed"].includes(batch.status)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true }); - if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); + // No new payment order once failure / non-payment was concluded. + if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); downloadContext.purpose = "loan_principal_repayment"; await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); } @@ -2096,8 +2184,11 @@ async function paymentBatchesRoutes(fastify) { // Becoming a same-amount competitor must serialize with settlement's // competitor check, which runs under the same lender lock (batch -> lender). await lockLender(tx, repayment.lender_id); - if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); - await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); + // After a failure / not-paid conclusion a late receipt is still recorded + // (state unchanged) so the final release re-check sees and blocks on it. + if (!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) { + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); + } } const created = []; for (const file of storedFiles) { @@ -2875,7 +2966,7 @@ async function paymentBatchesRoutes(fastify) { if (["executed", "cancelled"].includes(batch.status)) { throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409); } - if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason); + if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config); const openItemIds = batch.items .filter((item) => ["pending", "failed"].includes(item.status)) .map((item) => item.id); diff --git a/server/src/services/companyLoanPayments.js b/server/src/services/companyLoanPayments.js index d35bcdd8..609795f6 100644 --- a/server/src/services/companyLoanPayments.js +++ b/server/src/services/companyLoanPayments.js @@ -179,7 +179,8 @@ async function competingLoanBatches(tx, batch, repayment) { return tx.paymentBatch.findMany({ where: { id: { not: batch.id }, entity_type: "loan_repayment", loan_repayment: { lender_id: repayment.lender_id, amount: repayment.amount }, - OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }], + // Any bank-side trace counts: an export, a receipt or a registered submission. + OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }, { loan_bank_submissions: { some: {} } }], }, select: { id: true, batch_no: true, status: true }, orderBy: { id: "asc" } }); } @@ -224,7 +225,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) { async function settleLoanRepayment(tx, batch, request, config) { const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true }); - if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; await assertReceiptAttributed(tx, config, batch, repayment); await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" }, @@ -235,13 +236,29 @@ async function settleLoanRepayment(tx, batch, request, config) { await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length }); } -async function cancelLoanRepayment(tx, batch, request, reason) { +async function cancelLoanRepayment(tx, batch, request, reason, config) { // Stale identity/account must not prevent cancelling a never-sent request. const repayment = await readLoanRepayment(tx, batch); - if (!["not_sent", "confirmed_failed"].includes(repayment.bank_result_state) || + if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) || repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 || await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; + // "Never sent" must really mean nothing reached the bank side of the record. + if (repayment.bank_result_state === "not_sent" && ( + await tx.loanBankSubmission.count({ where: { payment_batch_id: batch.id } }) || + await tx.loanLegacyExportSummary.count({ where: { payment_batch_id: batch.id } }) || + await tx.loanBankTerminalEvidence.count({ where: { payment_batch_id: batch.id } }))) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + // Release re-validates the conclusion inside this transaction (batch lock held + // by the caller serializes it with new submissions and receipt uploads). + if (repayment.bank_result_state === "confirmed_not_paid") { + await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch); + } else if (repayment.bank_result_state === "confirmed_failed") { + const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, ""); + const verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } }); + if (!verification) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + const { fact } = await assertFailureFacts(tx, config, batch, repayment, verification.attachment_id); + if (fact.verification.id !== verification.id) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + } const ids = repayment.allocations.map((a) => a.loan_id).sort(); await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`); await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id }, data: { @@ -302,35 +319,74 @@ async function failedReceiptFact(tx, config, batch, attachmentId) { return { attachment, verification, fingerprint, keyId, verificationIds: [verification.id], manualProposalIds: [] }; } +// The failed slip must be the bank's answer to this batch's own instruction: +// its reference equals the attested submission's instruction reference (which +// is unique across batches). Otherwise another batch's failure could release it. +async function assertFailedSlipIsSubmission(tx, config, batch, fact, submissionIds) { + const { normalizeInstructionRef } = require("./loanBankTerminalFacts"); + const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionIds[0] } }); + const recognition = JSON.parse(decryptStringOrThrow(config, fact.verification.normalized_encrypted)); + if (submissionIds.length !== 1 || !submission || submission.payment_batch_id !== batch.id || + normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }; +} + +// A failed transaction must not be claimed as a payment anywhere, must use the +// current receipt key, and may back the release of only one batch. +async function assertFailureFingerprintFree(tx, batch, fact) { + if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: fact.keyId } } }) || + await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: fact.keyId } } }) || + await tx.loanFailureEvidenceUse.count({ where: { key_id: { not: fact.keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: fact.fingerprint }, { item_id: batch.items[0].id }] } }) || + await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: fact.fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + const used = await tx.loanFailureEvidenceUse.findUnique({ where: { fingerprint: fact.fingerprint } }); + if (used && used.payment_batch_id !== batch.id) { throw buildBusinessValidationError("loan.failure_evidence_reused", {}, 409); }; +} + +async function recordFailureEvidenceUse(tx, batch, fact, source) { + await assertFailureFingerprintFree(tx, batch, fact); + if (!await tx.loanFailureEvidenceUse.findUnique({ where: { fingerprint: fact.fingerprint } })) { + await tx.loanFailureEvidenceUse.create({ data: { fingerprint: fact.fingerprint, key_id: fact.keyId, payment_batch_id: batch.id, source } }); + } +} + +// Every condition behind a single-submission failure confirmation. Runs when +// confirming and again in the final cancel, so release never relies on a stale +// conclusion (late receipt, changed submission list, voided or re-read slip). +async function assertFailureFacts(tx, config, batch, repayment, attachmentId) { + const { assertSingleAttestedSubmission, assertVoidedReceiptsResolved } = require("./loanBankSubmissions"); + // Only non-voided material counts, but voiding never erases a bank fact. + const activeAttachments = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: null }, select: { id: true } }); + if (activeAttachments.length !== 1 || activeAttachments[0].id !== attachmentId) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + await assertVoidedReceiptsResolved(tx, config, batch); + // One failed instruction proves nothing about other submissions. + const submissionIds = await assertSingleAttestedSubmission(tx, batch); + const fact = await failedReceiptFact(tx, config, batch, attachmentId); + await assertFailedSlipIsSubmission(tx, config, batch, fact, submissionIds); + await assertFailureFingerprintFree(tx, batch, fact); + await require("./loanBankTerminalFacts").assertReleaseWaitElapsed(tx, config, batch); + // Same-lender, same-amount competitors: the failed receipt must be attributed + // to this batch by a reviewed failure attribution covering the attested list. + const competitors = await competingLoanBatches(tx, batch, repayment); + if (competitors.length) { + const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "batch", status: "approved", + covered_submission_ids: { equals: submissionIds } } }); + if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); }; + } + return { fact, submissionIds }; +} + async function confirmLoanBankFailure(tx, config, batch, request, attachmentId) { const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); - const { assertSingleAttestedSubmission, assertVoidedReceiptsResolved } = require("./loanBankSubmissions"); if (!["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`; // A bank fact after sending: frozen snapshot and approval-time identities; // the confirming reviewer is still judged by current eligibility. const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id, postPayment: true }); - // Only non-voided material counts, but voiding never erases a bank fact. - const activeAttachments = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: null }, select: { id: true } }); - if (activeAttachments.length !== 1 || activeAttachments[0].id !== attachmentId) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - await assertVoidedReceiptsResolved(tx, config, batch); - // One failed instruction proves nothing about other submissions (S1 gate). - const submissionIds = await assertSingleAttestedSubmission(tx, batch); - const fact = await failedReceiptFact(tx, config, batch, attachmentId); + const { fact, submissionIds } = await assertFailureFacts(tx, config, batch, repayment, attachmentId); // Independent of everyone who produced the bank-side evidence for this batch. if ((await reconciliationParticipants(tx, batch)).concat(fact.attachment.uploaded_by).includes(request.employee.id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; - if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: fact.keyId } } }) || - await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: fact.keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: fact.fingerprint }, { item_id: batch.items[0].id }] } }) || - await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: fact.fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - // Same-lender, same-amount competitors: the failed receipt must be attributed - // to this batch by a reviewed failure attribution covering the attested list. - const competitors = await competingLoanBatches(tx, batch, repayment); - if (competitors.length) { - const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", status: "approved" } }); - if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); }; - } + await recordFailureEvidenceUse(tx, batch, fact, "failure_confirmation"); await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`, } }); @@ -364,25 +420,40 @@ async function assertAttributableBatch(tx, batch, { requireCompetitors = true } // The evidence an attribution of the given kind binds to: the transaction the // next execute would claim (settlement), or the verified failed receipt plus // the attested submission list it covers (failure). -async function attributionEvidence(tx, config, batch, kind, attachmentId) { +async function attributionEvidence(tx, config, batch, kind, attachmentId, submissionId = null) { if (kind === "failure") { - const { assertSingleAttestedSubmission } = require("./loanBankSubmissions"); + const { assertSingleAttestedSubmission, submissionFacts, attestationMatches } = require("./loanBankSubmissions"); if (!attachmentId) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); }; - const coveredSubmissionIds = await assertSingleAttestedSubmission(tx, batch); - return { claim: await failedReceiptFact(tx, config, batch, attachmentId), coveredSubmissionIds }; + const claim = await failedReceiptFact(tx, config, batch, attachmentId); + if (!submissionId) { + const coveredSubmissionIds = await assertSingleAttestedSubmission(tx, batch); + await assertFailedSlipIsSubmission(tx, config, batch, claim, coveredSubmissionIds); + return { claim, coveredSubmissionIds, failureScope: "batch" }; + } + // Per-submission scope (S3): the failed slip must carry this submission's + // instruction reference, within a reviewed and unchanged submission list. + const { normalizeInstructionRef } = require("./loanBankTerminalFacts"); + const facts = await submissionFacts(tx, batch); + const submission = facts.active.find((item) => item.id === submissionId); + const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } }); + if (!submission || !attestationMatches(approvedList, facts)) { throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); }; + const recognition = JSON.parse(decryptStringOrThrow(config, claim.verification.normalized_encrypted)); + if (normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }; + return { claim, coveredSubmissionIds: [submissionId], failureScope: "submission" }; } - return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null }; + return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null, failureScope: "batch" }; } async function proposeReceiptAttribution(tx, config, batch, request, input) { const kind = input.kind ?? "settlement"; if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind) || !["settlement", "failure"].includes(kind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); }; const { competitors } = await assertAttributableBatch(tx, batch); - const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, kind, input.attachmentId); + const { claim, coveredSubmissionIds, failureScope } = await attributionEvidence(tx, config, batch, kind, input.attachmentId, kind === "failure" ? input.submissionId ?? null : null); // A still-valid open/approved record blocks duplicates. One whose receipt // transaction or competitor coverage no longer matches is closed as // superseded in the same transaction so the batch never deadlocks. - const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, status: { in: ["proposed", "approved"] } } }); + const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, failure_scope: failureScope, + status: { in: ["proposed", "approved"] }, covered_submission_ids: { equals: coveredSubmissionIds ?? [] } } }); if (active) { if (attributionMatches(active, claim, competitors, coveredSubmissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); }; await tx.loanReceiptAttribution.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } }); @@ -393,7 +464,7 @@ async function proposeReceiptAttribution(tx, config, batch, request, input) { const attribution = await tx.loanReceiptAttribution.create({ data: { payment_batch_id: batch.id, fingerprint: claim.fingerprint, key_id: claim.keyId, verification_ids: claim.verificationIds, manual_proposal_ids: claim.manualProposalIds, - kind, covered_submission_ids: coveredSubmissionIds ?? [], + kind, covered_submission_ids: coveredSubmissionIds ?? [], failure_scope: failureScope, competitor_batch_ids: competitors.map((item) => item.id), basis_kind: input.basisKind, basis_note: input.basisNote, exclusion_note: input.exclusionNote, proposed_by: request.employee.id, } }); @@ -415,7 +486,8 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI const { competitors } = await assertAttributableBatch(tx, batch); const attachmentId = attribution.kind === "failure" ? (await tx.paymentReceiptVerification.findUnique({ where: { id: attribution.verification_ids[0] } }))?.attachment_id : null; - const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, attribution.kind, attachmentId); + const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, attribution.kind, attachmentId, + attribution.kind === "failure" && attribution.failure_scope === "submission" ? attribution.covered_submission_ids[0] : null); if (!attributionMatches(attribution, claim, competitors, coveredSubmissionIds)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; // Also independent of everyone who produced the claimed evidence: uploaders // of every receipt carrying this transaction and any manual-receipt proposer. @@ -491,4 +563,5 @@ function mapRepayment(repayment, batch) { module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure, - proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES }; + proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES, + failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse }; diff --git a/server/src/services/loanBankSubmissions.js b/server/src/services/loanBankSubmissions.js index 0d9716bf..d21ebb5f 100644 --- a/server/src/services/loanBankSubmissions.js +++ b/server/src/services/loanBankSubmissions.js @@ -40,11 +40,27 @@ async function registerBankSubmission(tx, batch, request, input) { } else if (input.channel === "online_banking_import") { throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409); } + // Stored normalized so terminal evidence and slips can be bound to it; a + // bank instruction may belong to only one loan batch. + const { normalizeInstructionRef } = require("./loanBankTerminalFacts"); + const instructionRef = normalizeInstructionRef(input.bankInstructionRef); + if (instructionRef.length < 2) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + // Same batch: no second active row with this reference. Other batches: no row + // in any status, so a withdrawn instruction cannot be re-registered elsewhere. + if (await tx.loanBankSubmission.count({ where: { bank_instruction_ref: instructionRef, + OR: [{ payment_batch_id: batch.id, status: "active" }, { payment_batch_id: { not: batch.id } }] } })) { + throw buildBusinessValidationError("loan.instruction_ref_conflict", {}, 409); + } const submission = await tx.loanBankSubmission.create({ data: { payment_batch_id: batch.id, export_id: input.exportId ?? null, channel: input.channel, - submitted_at: new Date(input.submittedAt), bank_instruction_ref: input.bankInstructionRef, + submitted_at: new Date(input.submittedAt), bank_instruction_ref: instructionRef, customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id, } }); + // A registered submission means the instruction may have reached the bank: + // the repayment can no longer be treated as never sent. + const { readLoanRepayment } = loanPayments(); + const repayment = await readLoanRepayment(tx, batch); + if (repayment.bank_result_state === "not_sent") await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, { paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel }); return submission; @@ -148,9 +164,8 @@ async function assertSingleAttestedSubmission(tx, batch) { const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } }); if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); const [submission] = facts.active; - if (facts.exports.length && submission.export_id && submission.export_id !== facts.exports[0].id) { - throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409); - } + // The single export (if any) must be the one this submission imported. + if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409); return facts.activeIds; } @@ -162,45 +177,50 @@ async function assertSingleAttestedSubmission(tx, batch) { // or a live manual-receipt proposal keeps blocking. const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"]; -async function assertVoidedReceiptsResolved(tx, config, batch) { +// Per-attachment form of the voided-receipt rule (no database access): the +// attachment must include its verifications (newest first) and manual proposals. +function voidedAttachmentResolved(config, batch, attachment) { const { batchCandidates } = require("./paymentReceiptVerification"); const { matchBankSlip } = require("./paymentReceiptMatching"); + const { isClearBankFailure } = loanPayments(); + if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) return false; + if (attachment.verifications.some((verification) => verification.status === "matched")) return false; + const latest = attachment.verifications[0]; + if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) return false; + const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted)); + const pages = recognition.pages ?? []; + if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) || + recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) return false; + const candidates = batchCandidates(config, batch); + const digits = (value) => String(value ?? "").replace(/\D/g, ""); + for (const page of pages) { + if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) return false; + // A failed page counts only under the same strict rule as failure confirmation, + // applied page by page: a failed page proves no payment on that page, so a + // multi-page voided slip passes only if every page is a clear failure or a + // readable, clearly different payment. + if (page.transactionStatus === "failed") { + if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue; + return false; + } + // Any other page must be fully readable (the recognizer reports no issue). + if ((page.issues ?? []).length) return false; + if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) return false; + // "Clearly another payment": a definite mismatch AND at least one account + // differs from every candidate. Same accounts with a different amount may + // be a misread of this very payment, so it keeps blocking. + if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") return false; + if (candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) && + digits(candidate.payeeAccount) === digits(page.payeeAccount))) return false; + } + return true; +} + +async function assertVoidedReceiptsResolved(tx, config, batch) { const voided = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: { not: null } }, include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } }); - const unresolved = () => buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409); for (const attachment of voided) { - if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) throw unresolved(); - if (attachment.verifications.some((verification) => verification.status === "matched")) throw unresolved(); - const latest = attachment.verifications[0]; - if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) throw unresolved(); - const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted)); - const pages = recognition.pages ?? []; - if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) || - recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) throw unresolved(); - const { isClearBankFailure } = loanPayments(); - const candidates = batchCandidates(config, batch); - const digits = (value) => String(value ?? "").replace(/\D/g, ""); - for (const page of pages) { - if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) throw unresolved(); - // A failed page counts only under the same strict rule as failure confirmation, - // applied page by page: a failed page proves no payment on that page, so a - // multi-page voided slip passes only if every page is a clear failure or a - // readable, clearly different payment. - if (page.transactionStatus === "failed") { - if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue; - throw unresolved(); - } - // Any other page must be fully readable (the recognizer reports no issue). - if ((page.issues ?? []).length) throw unresolved(); - if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) throw unresolved(); - // "Clearly another payment": a definite mismatch AND at least one account - // differs from every candidate. Same accounts with a different amount may - // be a misread of this very payment, so it keeps blocking. - if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") throw unresolved(); - const sameAccounts = candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) && - digits(candidate.payeeAccount) === digits(page.payeeAccount)); - if (sameAccounts) throw unresolved(); - } + if (!voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409); } } @@ -224,4 +244,5 @@ async function bankSubmissionState(tx, batch) { module.exports = { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts, + attestationMatches, voidedAttachmentResolved, }; diff --git a/server/src/services/loanBankTerminalFacts.js b/server/src/services/loanBankTerminalFacts.js new file mode 100644 index 00000000..32d972f2 --- /dev/null +++ b/server/src/services/loanBankTerminalFacts.js @@ -0,0 +1,282 @@ +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { decryptStringOrThrow } = require("./crypto"); +const { auditLoan } = require("./companyLoans"); + +// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3). +// A repayment is released as "not paid" only when every reviewed bank submission +// has an independently reviewed terminal fact bound to its instruction reference, +// every export is linked to a submission, no payment receipt is left unexplained +// and the internal wait period has passed. The same check runs again in the +// final cancel transaction, so a stale conclusion can never release principal. + +const TERMINAL_KINDS = ["termination_evidence", "failure_receipt"]; +const TERMINATION_STATUSES = ["rejected", "revoked", "failed"]; +const PENDING_STATUSES = ["已受理", "受理成功", "处理中", "待处理"]; +const DEFAULT_RELEASE_WAIT_DAYS = 3; + +const loanPayments = () => require("./companyLoanPayments"); +const submissions = () => require("./loanBankSubmissions"); + +// Instruction references are compared after NFKC, whitespace removal and upper-casing. +function normalizeInstructionRef(value) { + return typeof value === "string" ? value.normalize("NFKC").replace(/\s+/g, "").toUpperCase() : ""; +} + +async function latestRecognition(tx, config, attachment) { + const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachment.id }, + orderBy: [{ created_at: "desc" }, { id: "desc" }] }); + if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) return null; + return { verification, recognition: JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)) }; +} + +// A pending slip of this batch's payment: a single complete page whose status is +// a pending wording (its only issue being transaction_pending), with every field +// read, matching the frozen payer/payee/amount/currency. Returns its reference. +async function pendingSlipReference(tx, config, batch, attachment) { + const { batchCandidates } = require("./paymentReceiptVerification"); + const { matchBankSlip } = require("./paymentReceiptMatching"); + const latest = await latestRecognition(tx, config, attachment); + const recognition = latest?.recognition; + if (!recognition || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages?.length !== 1) return null; + const page = recognition.pages[0]; + if (page.transactionStatus !== "pending" || !PENDING_STATUSES.includes(page.transactionStatusRaw) || + (page.issues ?? []).length !== 1 || page.issues[0] !== "transaction_pending" || !page.reference) return null; + const match = matchBankSlip({ ...page, transactionStatus: "success", issues: [] }, batchCandidates(config, batch)); + return match.status === "matched" ? normalizeInstructionRef(page.reference) : null; +} + +// A strictly clear failed slip of this batch's payment; returns verification and reference. +async function failureSlip(tx, config, batch, attachmentId) { + const fact = await loanPayments().failedReceiptFact(tx, config, batch, attachmentId); + const recognition = JSON.parse(decryptStringOrThrow(config, fact.verification.normalized_encrypted)); + return { ...fact, reference: normalizeInstructionRef(recognition.pages[0].reference) }; +} + +// Re-validates a terminal fact against the current batch state. Throws the +// reason when it no longer holds (submission withdrawn, evidence voided, the +// failed slip re-recognized or voided, a resolved pending slip changed). +async function assertTerminalFactHolds(tx, config, batch, fact, submission) { + if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); + if (fact.kind === "termination_evidence") { + const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } }); + if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); + } else { + let slip; + try { slip = await failureSlip(tx, config, batch, fact.receipt_attachment_id); } + catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); } + if (slip.verification.id !== fact.verification_id || slip.reference !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); + // Same checks as the single-submission failure path: the transaction is not + // claimed as a payment, uses the current key, backs no other batch, and with + // same-amount competitors needs a reviewed failure attribution for exactly + // this submission. + const { assertFailureFingerprintFree, competingLoanBatches, attributionMatches, readLoanRepayment } = loanPayments(); + await assertFailureFingerprintFree(tx, batch, slip); + const competitors = await competingLoanBatches(tx, batch, await readLoanRepayment(tx, batch)); + if (competitors.length) { + const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission", + status: "approved", covered_submission_ids: { equals: [submission.id] } } }); + if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); + } + } + for (const attachmentId of fact.resolves_attachment_ids ?? []) { + const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } }); + // Only a pending slip of the same instruction can be settled by this fact. + if (!attachment || attachment.payment_batch_id !== batch.id || + await pendingSlipReference(tx, config, batch, attachment) !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); + } +} + +function assertTerminalBatch(batch, repayment) { + if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) || + repayment.allocations.some((a) => a.reservation_state !== "reserved")) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); +} + +async function proposeTerminalFact(tx, config, batch, request, input) { + const { readLoanRepayment } = loanPayments(); + const repayment = await readLoanRepayment(tx, batch); + assertTerminalBatch(batch, repayment); + if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + await supersedeStaleTerminalFacts(tx, config, batch, request); + const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } }); + if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); + if (await tx.loanBankTerminalFact.count({ where: { submission_id: submission.id, status: { in: ["proposed", "approved"] } } })) { + throw buildBusinessValidationError("loan.terminal_fact_exists", {}, 409); + } + const data = { payment_batch_id: batch.id, submission_id: submission.id, kind: input.kind, note: input.note, + resolves_attachment_ids: [...new Set(input.resolvesAttachmentIds ?? [])], proposed_by: request.employee.id }; + if (input.kind === "termination_evidence") { + if (!TERMINATION_STATUSES.includes(input.terminalStatus) || !input.evidenceId) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + Object.assign(data, { terminal_status: input.terminalStatus, evidence_id: input.evidenceId }); + } else { + if (!input.receiptAttachmentId) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + let slip; + try { slip = await failureSlip(tx, config, batch, input.receiptAttachmentId); } + catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); } + Object.assign(data, { terminal_status: "failed", receipt_attachment_id: input.receiptAttachmentId, verification_id: slip.verification.id }); + } + await assertTerminalFactHolds(tx, config, batch, data, submission); + const fact = await tx.loanBankTerminalFact.create({ data }); + await auditLoan(tx, request, "LOAN_TERMINAL_FACT_PROPOSED", batch.loan_repayment_id, { + paymentBatchId: batch.id, factId: fact.id, submissionId: submission.id, kind: input.kind }); + return fact; +} + +// Everyone who produced the bank-side record of this batch, plus the batch +// creator, submitter and lender employee: none may review or confirm. +async function terminalParticipants(tx, batch, repayment) { + const { reconciliationParticipants } = loanPayments(); + const [facts, evidences] = await Promise.all([ + tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, select: { proposed_by: true } }), + tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }), + ]); + const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }); + return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)), + ...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean); +} + +async function reviewTerminalFact(tx, config, batch, request, factId, input) { + const { readLoanRepayment, activeReviewer } = loanPayments(); + const fact = await tx.loanBankTerminalFact.findUnique({ where: { id: factId } }); + if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + const repayment = await readLoanRepayment(tx, batch); + assertTerminalBatch(batch, repayment); + await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true }); + if (input.decision === "approve") { + const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } }); + await assertTerminalFactHolds(tx, config, batch, fact, submission); + if (fact.kind === "failure_receipt") { + await loanPayments().recordFailureEvidenceUse(tx, batch, await failureSlip(tx, config, batch, fact.receipt_attachment_id), "terminal_fact"); + } + } + const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: { + status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason } }); + await auditLoan(tx, request, input.decision === "approve" ? "LOAN_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED", + batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id }); + return reviewed; +} + +// Approved facts that no longer hold are closed as superseded so that a fresh +// fact can be registered for the same submission. +async function supersedeStaleTerminalFacts(tx, config, batch, request) { + // (Hoisted: also called after evidence is voided.) + const facts = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } }, + include: { submission: true } }); + for (const fact of facts) { + try { await assertTerminalFactHolds(tx, config, batch, fact, fact.submission); } + catch { + await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: { status: "superseded", superseded_at: new Date() } }); + await auditLoan(tx, request, "LOAN_TERMINAL_FACT_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id }); + } + } +} + +function releaseWaitDays(config) { + const value = Number(config?.loanReleaseWaitDays); + return Number.isFinite(value) && value >= 0 ? value : DEFAULT_RELEASE_WAIT_DAYS; +} + +// The full "not paid" release condition, evaluated under the batch lock both +// when confirming and again in the final cancel transaction. +async function assertNotPaidReleasable(tx, config, batch) { + const { submissionFacts, attestationMatches, voidedAttachmentResolved } = submissions(); + const facts = await submissionFacts(tx, batch); + if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409); + const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } }); + if (!facts.active.length || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); + const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } }); + const covered = new Set(); + const usedFacts = []; + for (const submission of facts.active) { + const fact = terminal.find((item) => item.submission_id === submission.id); + if (!fact) throw buildBusinessValidationError("loan.terminal_coverage_incomplete", {}, 409); + await assertTerminalFactHolds(tx, config, batch, fact, submission); + if (fact.receipt_attachment_id) covered.add(fact.receipt_attachment_id); + for (const id of fact.resolves_attachment_ids ?? []) covered.add(id); + usedFacts.push(fact.id); + } + // Every export must be accounted for by a submission that reached a terminal state. + const linkedExports = new Set(facts.active.map((item) => item.export_id).filter(Boolean)); + if (facts.exports.some((item) => !linkedExports.has(item.id))) throw buildBusinessValidationError("loan.export_unlinked", {}, 409); + // No payment receipt may stay unexplained: covered by a terminal fact, or a + // voided slip that is provably a failure or a clearly different payment. + const receipts = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, + include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } }); + for (const attachment of receipts) { + if (covered.has(attachment.id)) continue; + if (!attachment.archived_at || !voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.receipt_unresolved_for_release", {}, 409); + } + await assertReleaseWaitElapsed(tx, config, batch); + return usedFacts; +} + +// Internal wait after the last payment-order export, required on every release +// path (not-paid and single-submission failure), at confirmation and at cancel. +async function assertReleaseWaitElapsed(tx, config, batch) { + const { submissionFacts } = submissions(); + const facts = await submissionFacts(tx, batch); + const lastExport = [batch.payment_order_last_exported_at, facts.legacy?.last_exported_at, ...facts.exports.map((item) => item.exported_at)] + .filter(Boolean).map((value) => new Date(value).getTime()).reduce((max, value) => Math.max(max, value), 0); + if (lastExport && Date.now() - lastExport < releaseWaitDays(config) * 24 * 60 * 60 * 1000) throw buildBusinessValidationError("loan.release_wait_period", {}, 409); +} + +async function confirmNotPaid(tx, config, batch, request) { + const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); + const { readLoanRepayment, activeReviewer, validateLoanPayment } = loanPayments(); + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`; + const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true }); + assertTerminalBatch(batch, repayment); + if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true }); + const usedFacts = await assertNotPaidReleasable(tx, config, batch); + await tx.loanRepayment.update({ where: { id: repayment.id }, data: { + bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } }); + await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts }); +} + +async function terminalState(tx, batch) { + const [evidences, terminal] = await Promise.all([ + tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ uploaded_at: "asc" }, { id: "asc" }] }), + tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }] }), + ]); + return { + evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })), + facts: terminal.map((item) => ({ id: item.id, submissionId: item.submission_id, kind: item.kind, terminalStatus: item.terminal_status, + evidenceId: item.evidence_id, receiptAttachmentId: item.receipt_attachment_id, resolvesAttachmentIds: item.resolves_attachment_ids, + status: item.status, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })), + }; +} + +async function uploadTerminalEvidence(tx, batch, request, file) { + const { readLoanRepayment } = loanPayments(); + assertTerminalBatch(batch, await readLoanRepayment(tx, batch)); + // The same bank letter cannot back terminal facts of two batches. + if (await tx.loanBankTerminalEvidence.count({ where: { file_sha256: file.fileSha256, payment_batch_id: { not: batch.id } } })) { + throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409); + } + const evidence = await tx.loanBankTerminalEvidence.create({ data: { + payment_batch_id: batch.id, storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType, + file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id } }); + await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_UPLOADED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id }); + return evidence; +} + +// Voiding evidence invalidates every fact that relied on it. +async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) { + // After release the evidence is part of the closed record; later facts belong + // to the late-result reconciliation flow, not to rewriting this one. + assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch)); + const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } }); + if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409); + const voided = await tx.loanBankTerminalEvidence.update({ where: { id: evidence.id }, data: { + voided_at: new Date(), voided_by: request.employee.id, void_reason: reason } }); + await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_VOIDED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id }); + await supersedeStaleTerminalFacts(tx, config, batch, request); + return voided; +} + +module.exports = { + uploadTerminalEvidence, voidTerminalEvidence, + normalizeInstructionRef, proposeTerminalFact, reviewTerminalFact, supersedeStaleTerminalFacts, assertNotPaidReleasable, + confirmNotPaid, terminalState, terminalParticipants, DEFAULT_RELEASE_WAIT_DAYS, assertReleaseWaitElapsed, +}; diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index 03a65af8..d5fc088f 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -27,6 +27,15 @@ "loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。", "loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。", "loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。", + "loan.terminal_fact_invalid": "银行终态事实与提交、证据或回单不一致,或证据已作废、已变化,须重新登记。", + "loan.terminal_coverage_incomplete": "尚有银行提交没有经复核的终态事实,不能确认未付款或释放额度。", + "loan.export_unlinked": "有导出记录没有关联到已登记的银行提交,须先核实该次导出是否提交银行。", + "loan.receipt_unresolved_for_release": "本批次有银行回单尚未被终态事实覆盖,可能显示付款成功或处理中,不能释放额度。", + "loan.release_wait_period": "距最后一次导出未满内部等待期,暂不能确认未付款或释放额度。", + "loan.instruction_ref_conflict": "该银行指令号已登记在其他还款批次,请核对后重新登记。", + "loan.terminal_fact_exists": "该银行提交已有待复核或已通过的终态事实,请勿重复登记。", + "loan.terminal_evidence_invalid": "终态证据文件不符合要求,或已作废、不属于本批次。", + "loan.failure_evidence_reused": "该笔银行失败交易已作为其他还款批次的依据,不能重复使用。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",