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 4c63f77d..ec8f9a04 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,58 @@ +## 2026-10-07 S1、S2 本地实施记录(用户 2026-10-06 批准本地实施、针对性验证与只读复核;未提交、未推送、未部署;未做 S0 生产查询) + +S1(迁移 `20261007010000_loan_bank_submissions`): +- 新表 `payment_order_exports`:借款批次每次导出写一条(序号、导出人、文件 SHA-256、客户方流水号、金额),只增不改。 +- 新表 `loan_legacy_export_summaries`:迁移时回填存量借款批次已知导出次数,写明未知项(逐次明细、文件摘要、银行提交对应关系)。它不是一次真实导出或提交;存在时确认失败一律 409 `loan.submission_history_unverified`。 +- 新表 `loan_bank_submissions`:财务登记实际银行提交(网银导入须关联本批次导出记录),只能撤回一次,不能改。 +- 新表 `loan_bank_submission_attestations`:财务确认“现有有效提交即全部”,独立复核人须当前有资格,且不是确认人、批次创建人、提交人、出借员工、任一导出人或提交登记人;清单变化后旧确认失效,重新确认时同事务取代。 +- 确认失败:只计未作废附件(须恰为所确认的失败回单);作废附件曾被识别为成功或处理中、曾匹配、或有未驳回的人工录入,一律 409 `loan.voided_receipt_unresolved`;临时拦截(S3 前)——存在历史汇总、多于一条导出记录或多于一次有效提交、提交清单未经复核或已变化,均拒绝;改按付款后规则(出借员工离职不阻断,确认人按当前资格)。 + +S2(迁移 `20261007020000_loan_failure_attribution`): +- 归属确认新增 `kind`(settlement/failure)与 `covered_submission_ids`;每批次每类至多一条待复核或已通过;依据类型新增“回单载明本批次银行指令号”。 +- 存在同金额候选时,确认失败须有已复核、绑定失败回单证据与已确认提交清单的失败归属,否则 409 `loan.receipt_attribution_required`;失败归属登记前须先有已复核的提交清单。结算归属的查询全部限定 settlement 类,两类互不占位。 + +接口:`POST /payment-batches/:id/loan-bank-submissions`、`…/:submissionId/withdraw`、`…/loan-submission-attestation`、`…/:attestationId/review`;归属接口新增 `kind`、`attachmentId`;批次详情返回 `bankSubmissions`。新增 7 个原因码,三处登记。 + +页面:本步未做提交登记、清单复核和失败归属的界面(S8 页面整合)。**S1 收紧了确认失败,界面补齐前不得单独部署启用。** + +验证与复核: +- 首轮针对性验证:HTTP 39 组、浏览器 7 组、76 迁移 PASS;9 个资金守卫变异均变红(其中“去掉清单复核要求”首轮未变红,因缺“已登记未复核”反例,补后变红)。 +- 同模型只读复核:BLOCK,两项,均经副本实验复现。已修: + - B1 作废回单判定过宽(作废时尚未 OCR、状态不可读、状态歧义都会放行):改为“无法证明就拦截”。作废附件只有在当前版本识别为明确失败,或可读且确定不是本笔付款(金额/账户与本批次不符、无处理中或歧义标记)时才忽略;没有核验记录、未识别、状态不明、匹配过或有未驳回人工录入,一律 409。代价:无法识别的误传文件也会阻断,须以可读证据处理(留待 S3 作废复核或线下补证)。 + - B2 清单批准后“登记再撤回”会让旧批准重新生效:清单确认新增 `all_submission_ids`,绑定全部提交行(含已撤回)及全部导出,复核后的任何登记、撤回、导出都使批准失效,须重新确认并复核。 + - 同时补:确认失败人与失败归属复核人另排除全部导出人、提交登记人与清单确认人;清单复核人另排除已撤回提交的登记人;失败归属数据库层限定恰一条核验记录。 +- 修复后:HTTP 40 组、浏览器 7 组、76 迁移 PASS;修复对应的 5 个变异均变红(“忽略歧义状态标记”首轮未变红,补“标记失败但带处理中”反例后变红);server 4463、prisma validate、client typecheck/build、UI strict、错误码合同、敏感扫描、diff 检查通过。 +- 第二轮同模型只读复核:PASS_WITH_NOTES,无阻断,B1、B2 修复成立。按其意见加固:作废回单要判为“确定不是本笔”,须匹配结果明确不符、金额大于 0,且付款与收款账号不能都与本批次一致(同账号仅金额不同可能是本笔误读,继续阻断);作废回单逐页核对页数完整;补数据库层反例(清单确认的全部提交、导出集合不可改,已撤回提交不能恢复)。“同账号放行”变异变红。最终:HTTP 40 组、浏览器 7 组、76 迁移,server 4463 等全部通过。 +- **既有问题(第二轮复核发现,自 09-06 即存在,方向为拦截;已按下条口径修复)**:真实 OCR 只要页面带任何问题标记就整体判为 `needs_review`,失败回单必带 `transaction_failed`,而 `failedReceiptFact` 要求 `recognized`。因此用真实 OCR 时“确认失败”永远返回 409,作废失败回单的放行分支也是死代码。测试夹具伪造了真实识别器不会产出的“已识别且带失败标记”结果,正向用例没有覆盖真实路径。修正需定义失败回单的识别口径(如仅允许唯一问题为 `transaction_failed`),属资金规则,待用户决定;在此之前生产中任何银行失败都无法在系统内确认。 +- 失败回单识别口径(用户 2026-10-07 同意并入 S1/S2,口径为 4 种失败措辞): + - 归一化新增 `transactionStatusRaw`(银行原始状态措辞),随核验结果存储;匹配逻辑不变,报销与成功核销不受影响(相关共享单测 237 项通过)。 + - 不改顶层识别状态。失败事实另用严格判定 `isClearBankFailure`:单页且页数完整;状态为失败且原始措辞属于“交易失败、转账失败、付款失败、支付失败”;唯一问题标记为 `transaction_failed`;金额、币种、双方账号、流水号齐全;再经与冻结快照匹配。它只用于确认失败,不能用于成功核销,也不绕过提交清单、失败归属与独立复核。“已撤销”“已冲正”“已退汇”不接受,分别留给 S3 指令终态与后续退款对账流程。作废回单中的失败页也按同一判定,否则继续阻断。 + - 测试改走真实处理链:只替换云端 `BankSlipOCR` 响应,从合成银行字段经 PDF 解析、`recognizePaymentReceipt`、`normalizeBankSlip`、核验存储、匹配与认领。仅两个纵深防御用例显式使用合成归一化结果(作废路径“失败但带处理中标记”、确认路径“失败但另带账号歧义标记”),二者真实归一化无法产出。新增用例:“转账失败”可确认;“已撤销/已冲正/已退汇”、缺流水号、两页回单、金额不符、带额外问题标记均拒绝;作废的“已撤销”回单继续阻断。 + - 变异:接受任意失败措辞、要求顶层 recognized、作废失败页不按严格判定三者变红;“忽略多余问题标记”“跳过字段完整性”未变红——下游 `matchBankSlip` 对任何剩余问题或缺失字段都会判不匹配,二者为纵深防御,测试无法单独区分。 + - 结果:HTTP 41 组、浏览器 7 组、76 迁移,server 4463、client typecheck/build、UI strict、错误码合同、敏感扫描、diff、治理检查均通过。真实建行回单接受性仍待验。 + - 只读复核(同模型):BLOCK 一项——同页两个状态字段(如“交易失败”与“已撤销”,都归一化为失败)不被判歧义,原始措辞只取首个字段,按 OCR 字段顺序可绕过白名单(复核实验证实)。已修:仅当所有状态字段原文完全相同时才记录 `transactionStatusRaw`,否则为空即拒绝;`transactionStatus` 与问题标记不变,报销与成功匹配不受影响。补用例:两种字段顺序的“失败+已撤销”均拒绝、作废的“失败+已撤销”回单继续阻断、“付款失败”“支付失败”正向用例。其余检查(原文可靠性、严格判定、成功路径未放宽、测试走真实链)复核成立。 +- 复核其余非阻断(留待 S3/S8):导出记录未被手工提交关联时仍可确认失败(S3 须落实每条导出都关联已终止提交或人工确认未提交);失败归属状态与提交登记、清单复核无界面;部分反例未覆盖(导出失败时导出记录回滚、回填 SQL 在有存量时的效果、报销导出不写导出记录)。 + +## 2026-10-06 对账方案实施拆分(依据 design 第 3 版;待用户批准,未实施) + +通用规则: +- 每步只做新增型迁移,均涉及资金规则,完成后必须只读复核。 +- 每步可单独验证、单独提交,但**不代表可以单独部署启用**。部署时机另行批准,提交须单独确认。 +- 验证按改动范围安排:相关的数据库、HTTP、权限与并发回归,加上针对资金守卫的变异测试;改到共享付款路径时扩大回归;阶段完成后再跑完整前后端测试。 +- 完整的释放条件在 S3 之前尚未实现,所以 S1、S2 不得放宽任何释放结果,只能修正误判并保留拦截(见 S1 第 3 条)。 + +| 步骤 | 内容 | 对应 | 依赖 | +|---|---|---|---| +| S0 前提核实(不写代码) | ① 生产有无真实还款与存量批次(只读查询须用户当轮授权);② 是否至少有 2 名与单据无关、当前有资格的经理或总经理;③ 建设银行能否出具“指令查询”官方 PDF,含查询无结果的情况(用户线下确认)。三项均不默认为已满足;不阻塞本地合成开发,但②③决定 S4 能否启用,①为上线前必须完成的存量核实 | AC16、AC12 | — | +| S1 导出与提交记录、确认失败修正 | 1. 新增制单导出记录表(序号、时间、导出人、文件摘要、客户方流水号、金额、快照摘要),每次导出写入。
2. 新增银行提交尝试登记(财务登记,关联导出记录与银行指令号),提交清单须经独立复核。
3. 确认失败:只计未作废附件,但作废的成功或处理中回单仍阻断;**同时保留拦截**——批次存在多于一条导出记录、提交清单未经复核、或清单中不止一次提交时,一律拒绝确认失败,留待 S3 的逐次终态规则处理。
4. 确认失败改按付款后规则:核对冻结快照,不要求出借员工在职,当前操作人按当前资格。
5. 存量:保留已知的 `payment_order_export_count`,新增“历史导出汇总”标明逐次明细、文件与银行提交对应关系待核实;该汇总不是一次真实导出或提交,存在它的批次在确认失败与释放时按“提交未明”拦截 | R2、R4、AC9、AC16 | — | +| S2 失败归属 | 归属确认新增 `failure` 类,须写明覆盖的银行指令并绑定失败回单证据;存在同金额候选时,确认失败须有已复核的失败归属,否则保持占用;S1 第 3 条的拦截继续有效 | R3、AC10 | S1 | +| S3 银行终态确认与释放 | 新增 `terminated` 类与 `confirmed_not_paid` 状态(含触发器);释放条件:已复核提交清单中每一次提交都有覆盖它的终态或失败证据,每条导出记录都关联已终止的提交(或有 S4 的人工确认),且无未解决的成功或处理中事实;之后取代 S1 第 3 条的临时拦截;释放后禁止再导出,页面提示原制单文件不得再导入网银 | R1(银行确认)、AC11 | S1、S2 | +| S4 人工确认未提交(Q6) | 新增 `not_submitted` 类与 `confirmed_not_submitted` 状态;全部证据加两名不同的独立复核人;记录与页面标为人工确认;人员不足或材料缺失时继续占用 | R1(人工例外)、AC12 | S3;能否启用取决于 S0 ②③ | +| S5 冻结原因与解冻 | 新增冻结原因表(类型、来源、状态、关闭依据);冻结拦截该出借人的新申请,以及其他在途申请的复核与导出,放行事实登记;误报原因经独立复核关闭;全部原因关闭才解冻 | AC13 | —(S6 的前置) | +| S6 晚到成功补记 | 晚到事实只增不改、标记原结论被推翻;登记即产生冻结原因;按冻结快照与历史审批核对;逐笔先处理占用冲突,再按差额结清,余额生成待追回原因;补记还款不生成付款批次、不可导出;覆盖已释放与已确认失败未释放两种情况 | R5、AC14 | S3、S5 | +| S7 追回与解冻 | 凭到账回单经独立复核登记追回,支持部分追回,全额后关闭对应原因;本期不支持冲抵 | AC15 | S6 | +| S8 收口 | 页面整合与手机端走查;恢复手册补充;完整前后端回归;把 AC9–AC16 的验证结果与剩余缺口回填到 PRD 与本文件 | 全部 | S1–S7 | + ## 2026-10-06 C 回单批次归属保守拦截(用户批准实施;本地合成,未提交、未部署) 规则:同一出借人、同金额的其他借款批次只要曾导出制单或上传回单(含确认失败后已取消的),本批次的匹配回单就不能证明归属。 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 80f30a61..055f68d4 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -55,6 +55,14 @@ - [x] AC6:付款完成可追到原借款分配和会计交接状态;余额查询标明截止/核对状态,不把OA无记录说成银行无还款,不把本金计入费用。 - [x] AC7:报销/差旅既有付款、独立复核、制单、回单和权限回归通过;PC/手机沿既有交互,机密字段默认脱敏,普通员工不能读全台账。 - [ ] AC8:迁移与回退影响、独立只读复核、恢复风险处置和发布前条件完整;本地合成通过不替代真实银行接受性或部署,真实录入/付款另批。 +- [ ] AC9(对账,design 3.1/3.3):制单导出与银行提交尝试分别落表并关联;提交清单须经独立复核;确认失败只计未作废附件,但作废的成功或处理中回单仍按事实阻断;确认失败按批次冻结快照核对,出借员工离职不阻断;一条指令的终态不能推定其他提交已结束。 +- [ ] AC10(失败归属,3.1):存在同金额候选时,确认失败须有已复核、写明覆盖哪些指令的失败归属;未确认时保持占用。 +- [ ] AC11(银行终态释放,3.2):每次已登记提交均有能唯一定位指令、经独立复核的终态证据,且无未解决的成功或处理中事实,才可转 `confirmed_not_paid` 并释放;对账单、截图、时间依据与等待期均不能单独放行;释放后禁止再导出。 +- [ ] AC12(人工确认未提交,3.2a,用户 2026-10-06 确认支持):全部证据加两名不同的独立复核人,转 `confirmed_not_submitted`,记录与页面标为人工确认;人员不足或材料缺失时继续占用。 +- [ ] AC13(冻结,3.5):冻结按原因记录;拦截新申请及其他在途申请的复核与导出,不拦事实登记;误报经独立复核关闭不补记;证据不确定继续冻结;全部原因关闭才解冻。 +- [ ] AC14(晚到成功,3.4):只增不改并标记原结论被推翻;按冻结快照与历史审批核对;覆盖已释放与已确认失败未释放;逐笔先处理占用冲突再按差额结清,余额列待追回;补记不生成可导出指令,整笔超额也完整记录;同一交易不能两次使用。 +- [ ] AC15(追回,3.5):凭到账证据经独立复核登记,支持部分追回;仅登记不解冻;本期不支持冲抵。 +- [ ] AC16(存量):历史批次保留已知的导出次数,新增“历史导出汇总”标明已知与待核实信息;它不是一次真实导出或银行提交,不能据此放行额度;上线前须核实生产存量(S0)。 ## Out of Scope diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index 15cf8dcd..593c2bac 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -24,7 +24,7 @@ export type LoanRepayment = { allocations: Array<{ loanId: string; loanNo: string; amount: string; state: string; receivedDate: string; originReference: string; balanceAsOf: string; evidences: LoanEvidence[] }>; receiptAttribution?: LoanReceiptAttributionState; }; -export type LoanAttributionBasisKind = "customer_serial" | "bank_memo" | "bank_written_confirmation" | "competitors_resolved"; +export type LoanAttributionBasisKind = "customer_serial" | "bank_memo" | "bank_written_confirmation" | "competitors_resolved" | "bank_instruction_ref"; export type LoanReceiptAttributionState = { required: boolean; competitors: Array<{ id: string; batchNo: string; status: string }>; diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index ce325e90..7fded9c1 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -22,6 +22,13 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。", "loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。", "loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。", + "loan.submission_required": "须先登记本批次实际向银行提交的指令,才能确认提交清单。", + "loan.submission_list_unverified": "银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。", + "loan.submission_history_unverified": "该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。", + "loan.multiple_submissions_unresolved": "该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。", + "loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。", + "loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。", + "loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。", "config.document_type_invalid": "单据类型标识不符合要求,请重新选择单据类型。", "document_sequence.type_unsupported": "当前单据类型不支持编号,请联系管理员核对。", diff --git a/client/src/components/common/LoanPaymentDetails.tsx b/client/src/components/common/LoanPaymentDetails.tsx index cfd28fd7..1052e72b 100644 --- a/client/src/components/common/LoanPaymentDetails.tsx +++ b/client/src/components/common/LoanPaymentDetails.tsx @@ -15,6 +15,7 @@ const attributionBasisLabels: Record = { bank_memo: "回单附言或用途载明本批次号", bank_written_confirmation: "银行书面查询结果指明该交易对应本批次指令", competitors_resolved: "其他候选批次均已对应其他交易或经银行确认未付款", + bank_instruction_ref: "回单载明本批次的银行指令号", }; const attributionStatusLabels = { proposed: "待独立复核", approved: "已通过独立复核", rejected: "已驳回", superseded: "已失效(已被新登记取代)" } as const; diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index 8a3ff9fc..96dec04e 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -163,18 +163,42 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready'); checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review'); const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob'); - async function receipt(batchId, amount, reference, overrides = {}) { + // Receipts go through the real local chain: PDF decode, recognizePaymentReceipt, + // normalizeBankSlip, verification storage, matching and claims. Only the cloud + // BankSlipOCR response is synthetic, built from the intended slip fields. + const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr'); + const { PDFDocument } = req('pdf-lib'); + const receiptPdf = async (pages = 1) => { const doc = await PDFDocument.create(); for (let i = 0; i < pages; i++) doc.addPage([200, 200]); return Buffer.from(await doc.save()); }; + const onePagePdf = await receiptPdf(1); + const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' }; + function bankSlipFields(slip) { + const fields = []; + const add = (Name, Value) => { if (Value !== null && Value !== undefined) fields.push({ Name, Value: String(Value) }); }; + if (slip.amount !== null && slip.amount !== undefined) { + const cents = BigInt(slip.amount); + add('小写金额', `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`); + } + add('币别', slip.currency === 'CNY' ? '人民币' : slip.currency); + add('付款人账号', slip.payerAccount); add('收款人账号', slip.payeeAccount); add('凭证号', slip.reference); + for (const wording of slip.statusWordings ?? (slip.transactionStatus ? [STATUS_WORDING[slip.transactionStatus]] : [])) { + add(fields.some((field) => field.Name === '单据状态') ? '交易状态' : '单据状态', wording); + } + return fields; + } + async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false } = {}) { const boundary = `synthetic-${randomUUID()}`; + const file = pdfPages === 1 ? onePagePdf : await receiptPdf(pdfPages); const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, - payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); + payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(response.statusCode, 200, 'receipt upload'); - const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, - pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, - transactionStatus: 'success', reference, issues: [], ...overrides }] }; - // Only the cloud recognizer is replaced. Queue claiming, local storage, - // normalization, verification, transaction claims and HTTP writes are real. - const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition }); + const slip = { amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, ...overrides }; + // normalizedPage: one explicitly synthetic defence-in-depth case that the + // real normalizer cannot produce; every other receipt uses the real chain. + const recognize = normalizedPage + ? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip }] }) + : (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) }); + const result = await runReceiptOcrOnce({ prisma, config, recognize }); return { attachment: response.json().data.items[0], result }; } const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} }); @@ -221,11 +245,24 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' }); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80'); checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel'); + // 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 } = {}) { + 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: '合成网银提交登记' }); + 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 }; + } const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] }); await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {}); const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] }); await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403); + await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 409, 'loan.submission_list_unverified'); + await attestSubmission(failedBank.id); await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }); await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' }); @@ -299,12 +336,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403); // Fresh lenders below: the first lender keeps unknown-state reservations from // the negative receipt loop above, which by design block its maintenance. - async function verifiedLender(name, employeeId, digit) { + async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) { const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name, - bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: digit.repeat(16), accountName: name }); + bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name }); const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, { expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' }); - return { ...confirmedLender, bankAccount: digit.repeat(16) }; + return { ...confirmedLender, bankAccount: account }; } async function confirmedLoan(lenderId, principal, ref) { const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref, @@ -630,11 +667,177 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { } finally { await racePool.end(); } checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders'); + // S1: exports and bank submissions are separate facts; failure confirmation + // is gated on one attested submission and on voided receipts being resolved. + const s1Lender = await verifiedLender('合成出借人辛', null, '1', '1357913579135791'); + const s1Loan = await confirmedLoan(s1Lender.id, '1000.00', '合成辛借入'); + const s1Batch = async (amount) => { const item = await repay(s1Lender.id, s1Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; }; + const failReceipt = (batchId, cents, ref) => receipt(batchId, cents, ref, { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); + const confirmFailure = (batchId, attachmentId, status = 200, reason) => call(manager, 'POST', `/payment-batches/${batchId}/bank-failure-review`, { attachmentId }, status, reason); + // Two exports: recorded separately; one failed receipt cannot close both. + const twice = await s1Batch('11.00'); + await exportOrder(twice.id); await exportOrder(twice.id); + const exportsState = (await detail(twice.id)).loanRepayment.bankSubmissions.exports; + assert.deepEqual(exportsState.map((item) => item.sequence), [1, 2]); + assert.ok(exportsState.every((item) => Array.isArray(item.customerSerials) && item.customerSerials.length === 1)); + const twiceFail = await failReceipt(twice.id, '1100', 'SYNTHETIC-S1-TWICE'); + await attestSubmission(twice.id); + await confirmFailure(twice.id, twiceFail.attachment.id, 409, 'loan.multiple_submissions_unresolved'); + checks.push('two_exports_recorded_and_single_failure_cannot_close_them'); + // A voided receipt that once showed success or pending keeps blocking. + for (const [label, status, cents] of [['success', 'success', '1200'], ['pending', 'pending', '1300']]) { + const voidedCase = await s1Batch(String(Number(cents) / 100) + '.00'); + const earlier = await receipt(voidedCase.id, cents, `SYNTHETIC-S1-VOIDED-${label}`, { payeeAccount: s1Lender.bankAccount, + transactionStatus: status, issues: status === 'pending' ? ['transaction_pending'] : [] }); + await call(finance, 'POST', `/payment-batches/${voidedCase.id}/receipt-attachments/${earlier.attachment.id}/void`, { reason: '合成作废' }); + const laterFail = await failReceipt(voidedCase.id, cents, `SYNTHETIC-S1-AFTER-${label}`); + await attestSubmission(voidedCase.id); + await confirmFailure(voidedCase.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved'); + } + checks.push('voided_success_or_pending_receipt_keeps_blocking_failure'); + // Voided material fails closed: without readable proof that it is a failure + // or a clearly different transaction, it keeps blocking. + const voidedBlocks = [ + ['before_ocr', null], + ['unreadable', { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }], + ['ambiguous_status', { transactionStatus: null, statusWordings: ['交易失败', '处理中'] }], + // Explicitly synthetic (real normalization cannot produce it): defence in depth. + ['failed_with_pending_issue', { transactionStatus: 'failed', transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'transaction_pending'] }], + // Same payer and payee, different amount: possibly a misread of this payment. + ['same_accounts_other_amount', { amount: '1' }], + // A voided "revoked" slip is not a clear failure: it keeps blocking. + ['voided_revoked', { transactionStatus: 'failed', statusWordings: ['已撤销'] }], + ['voided_failed_plus_revoked', { transactionStatus: 'failed', statusWordings: ['交易失败', '已撤销'] }], + ]; + const voidedAmounts = { before_ocr: '17', unreadable: '18', ambiguous_status: '19', failed_with_pending_issue: '21', same_accounts_other_amount: '22', voided_revoked: '23', voided_failed_plus_revoked: '24' }; + for (const [label, overrides] of voidedBlocks) { + const blocked = await s1Batch(`${voidedAmounts[label]}.00`); + const cents = `${voidedAmounts[label]}00`; + let voidedId; + if (overrides === null) { + const boundary = `synthetic-${randomUUID()}`; + const raw = await app.inject({ method: 'POST', url: `/api/payment-batches/${blocked.id}/receipt-attachments`, + headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); + voidedId = raw.json().data.items[0].id; + } else { + voidedId = (await receipt(blocked.id, cents, `SYNTHETIC-S1-${label}`, { payeeAccount: s1Lender.bankAccount, ...overrides }, + { normalizedPage: label === 'failed_with_pending_issue' })).attachment.id; + } + await call(finance, 'POST', `/payment-batches/${blocked.id}/receipt-attachments/${voidedId}/void`, { reason: '合成作废' }); + const laterFail = await failReceipt(blocked.id, cents, `SYNTHETIC-S1-AFTER-${label}`); + await attestSubmission(blocked.id); + await confirmFailure(blocked.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved'); + } + checks.push('voided_without_readable_proof_fails_closed'); + // A voided, readable success receipt of a different payment no longer blocks; + // attestation independence; any later add or withdraw invalidates approval. + const misupload = await s1Batch('14.00'); + const otherPayment = await receipt(misupload.id, '9900', 'SYNTHETIC-S1-OTHER-PAYMENT', { payeeAccount: '9'.repeat(16) }); + await call(finance, 'POST', `/payment-batches/${misupload.id}/receipt-attachments/${otherPayment.attachment.id}/void`, { reason: '合成误传他笔回单' }); + const misuploadFail = await failReceipt(misupload.id, '1400', 'SYNTHETIC-S1-MISUPLOAD'); + await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成无提交清单' }, 409, 'loan.submission_required'); + await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'online_banking_import', + 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: '合成手工提交登记' }); + 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. + await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); + const misAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成提交清单完整' }); + await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); + await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成重复提交清单' }, 409, 'loan.submission_attestation_exists'); + await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成登记人自审' }, 403); + await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成清单复核' }); + const extra = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'other', + submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-EXTRA', note: '合成补登第二次提交' }); + await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.multiple_submissions_unresolved'); + await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成误登撤回' }); + await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成重复撤回' }, 409); + // The unreviewed add-then-withdraw does not revive the earlier approval. + assert.equal((await detail(misupload.id)).loanRepayment.bankSubmissions.attestation.stale, true); + await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); + const reAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成撤回后重新确认清单' }); + assert.equal((await prisma.loanBankSubmissionAttestation.findUniqueOrThrow({ where: { id: misAttest.id } })).status, 'superseded'); + await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${reAttest.id}/review`, { decision: 'approve', reason: '合成重新复核' }); + // The submission registrar (even with manager rights) cannot confirm the failure. + await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/bank-failure-review`, { attachmentId: misuploadFail.attachment.id }, 403); + await confirmFailure(misupload.id, misuploadFail.attachment.id); + await call(finance, 'POST', `/payment-batches/${misupload.id}/cancel`, { reason: '合成确认失败后释放' }); + assert.ok(firstSubmission.id); + checks.push('voided_other_payment_ignored_attestation_independent_and_any_list_change_invalidates'); + // A pre-existing batch carries only a known export count: never treated as + // a real export or submission; failure confirmation stays blocked. + const legacyBatch = await s1Batch('15.00'); + await prisma.$executeRaw`INSERT INTO loan_legacy_export_summaries (payment_batch_id, known_export_count) VALUES (${legacyBatch.id}::uuid, 2)`; + const legacyFail = await failReceipt(legacyBatch.id, '1500', 'SYNTHETIC-S1-LEGACY'); + const legacyState = (await detail(legacyBatch.id)).loanRepayment.bankSubmissions; + assert.equal(legacyState.legacy.knownExportCount, 2); assert.equal(legacyState.exports.length, 0); + await confirmFailure(legacyBatch.id, legacyFail.attachment.id, 409, 'loan.submission_history_unverified'); + checks.push('legacy_export_summary_keeps_known_count_and_blocks_failure'); + // Failure confirmation is a post-payment fact: a departed lender employee + // does not block it; the confirming reviewer is still judged currently. + const departingLender = await actor(['employee']); + const staffFailLender = await verifiedLender('合成出借人壬', departingLender.id, '2', '2468024680246802'); + const staffFailLoan = await confirmedLoan(staffFailLender.id, '100.00', '合成壬借入'); + const staffFail = await repay(staffFailLender.id, staffFailLoan.id, '16.00'); + await call(manager, 'POST', `/payment-batches/${staffFail.id}/review`, {}); + const staffFailReceipt = await receipt(staffFail.id, '1600', 'SYNTHETIC-S1-DEPARTED', { payeeAccount: staffFailLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); + await attestSubmission(staffFail.id); + await setStatus(departingLender, 'archived'); + await call(manager, 'POST', `/payment-batches/${staffFail.id}/bank-failure-review`, { attachmentId: staffFailReceipt.attachment.id }); + await setStatus(departingLender, 'active'); + checks.push('failure_confirmation_after_lender_employee_departure'); + // Failure wording: only the four "failed" wordings prove no money left; + // revoked/reversed/returned, incomplete or mismatched slips are refused. + const wordingCases = [ + ['转账失败', {}, 200, null], + ['付款失败', {}, 200, null], + ['支付失败', {}, 200, null], + // Two status fields that both normalize to "failed": refused in either order. + ['交易失败', { statusWordings: ['交易失败', '已撤销'] }, 409, 'loan.bank_state_unsafe'], + ['已撤销', { statusWordings: ['已撤销', '交易失败'] }, 409, 'loan.bank_state_unsafe'], + ['已撤销', {}, 409, 'loan.bank_state_unsafe'], + ['已冲正', {}, 409, 'loan.bank_state_unsafe'], + ['已退汇', {}, 409, 'loan.bank_state_unsafe'], + ['交易失败', { reference: null }, 409, 'loan.bank_state_unsafe'], + ['交易失败', { pdfPages: 2 }, 409, 'loan.bank_state_unsafe'], + ['交易失败', { amount: '1' }, 409, 'loan.bank_state_unsafe'], + // Explicitly synthetic: an extra issue besides the failure itself. + ['交易失败', { normalizedPage: true, transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'payerAccount_ambiguous'] }, 409, 'loan.bank_state_unsafe'], + ]; + let wordingAmount = 30; + for (const [wording, variant, status, reason] of wordingCases) { + wordingAmount += 1; + const item = await s1Batch(`${wordingAmount}.00`); + const { pdfPages, normalizedPage, ...slip } = variant; + const failed = await receipt(item.id, `${wordingAmount}00`, `SYNTHETIC-WORDING-${wordingAmount}`, + { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', statusWordings: [wording], ...slip }, { pdfPages: pdfPages ?? 1, normalizedPage }); + assert.notEqual(failed.result.status, 'matched'); + await attestSubmission(item.id); + await confirmFailure(item.id, failed.attachment.id, status, reason); + } + checks.push('only_four_failure_wordings_with_complete_single_page_slip_confirm_failure'); + // 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 } })); + await assert.rejects(prisma.paymentOrderExport.delete({ where: { id: anyExport.id } })); + await assert.rejects(prisma.loanBankSubmission.update({ where: { id: firstSubmission.id }, data: { note: '合成改写提交' } })); + await assert.rejects(prisma.loanLegacyExportSummary.update({ where: { payment_batch_id: legacyBatch.id }, data: { known_export_count: 1 } })); + await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: misAttest.id }, data: { status: 'rejected' } })); + await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { all_submission_ids: [] } })); + await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { export_ids: ['00000000-0000-0000-0000-000000000000'] } })); + await assert.rejects(prisma.loanBankSubmission.update({ where: { id: extra.id }, data: { status: 'active', withdrawn_at: null, withdrawn_reason: null } })); + checks.push('reconciliation_history_append_only_in_database'); + // C2: old batch confirmed failed and cancelled; its late success receipt // lands on a new same-amount batch, via OCR and via manual receipt review. const c2old = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {}); const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); + await attestSubmission(c2old.id); await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id }); await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' }); const c2new = await repay(probeLender.id, probeLoan.id, '6.00'); @@ -668,9 +871,27 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {}); const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); - await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failReceipt.attachment.id }, 409, 'loan.receipt_attribution_required'); + const c2failAttachment = c2failReceipt.attachment.id; + await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.submission_list_unverified'); + const failureAttribution = { kind: 'failure', attachmentId: c2failAttachment, basisKind: 'bank_instruction_ref', + basisNote: '合成失败回单载明本批次网银指令号', exclusionNote: '合成其他同额批次的指令号与本回单不同' }; + await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.submission_list_unverified'); + await attestSubmission(c2failNew.id); + await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.receipt_attribution_required'); await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe'); checks.push('ambiguous_failed_receipt_cannot_release_reservation'); + // S2: a reviewed failure attribution covering the attested submission unlocks it. + const failureProposal = await attribute(finance, c2failNew.id, failureAttribution); + await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.receipt_attribution_exists'); + await reviewAttribution(finance, c2failNew.id, failureProposal.id, 'approve', 403); + await reviewAttribution(independent, c2failNew.id, failureProposal.id, 'approve'); + const settlementSlot = (await detail(c2failNew.id)).loanRepayment.receiptAttribution.current; + assert.equal(settlementSlot, null, 'failure attribution does not occupy the settlement slot'); + const beforeFailureRelease = (await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available; + await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }); + await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '合成失败归属确认后释放' }); + assert.equal(Number((await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available), Number(beforeFailureRelease) + 6); + checks.push('failure_attribution_reviewed_unlocks_ambiguous_failure_release'); // No competitor: attribution is neither required nor accepted. const soloLender = await verifiedLender('合成出借人己', null, '1'); diff --git a/server/prisma/migrations/20261007010000_loan_bank_submissions/migration.sql b/server/prisma/migrations/20261007010000_loan_bank_submissions/migration.sql new file mode 100644 index 00000000..b575b8bd --- /dev/null +++ b/server/prisma/migrations/20261007010000_loan_bank_submissions/migration.sql @@ -0,0 +1,162 @@ +-- S1 of the loan bank-result reconciliation design (design.md 3.1): +-- payment-order exports and bank submissions are different facts. One export +-- file may be submitted zero, one or several times; the reviewed submission +-- list, not the export count, is what failure/termination evidence must cover. + +-- CreateTable +CREATE TABLE "payment_order_exports" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "sequence" INTEGER NOT NULL, + "exported_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "exported_by" UUID NOT NULL, + "file_sha256" VARCHAR(64) NOT NULL, + "customer_serials" JSONB NOT NULL, + "total_amount" DECIMAL(14,2) NOT NULL, + + CONSTRAINT "payment_order_exports_pkey" PRIMARY KEY ("id"), + CONSTRAINT "payment_order_exports_sequence_check" CHECK ("sequence" > 0), + CONSTRAINT "payment_order_exports_sha_check" CHECK ("file_sha256" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "payment_order_exports_serials_check" CHECK ( + jsonb_typeof("customer_serials") = 'array' AND jsonb_array_length("customer_serials") > 0) +); +CREATE UNIQUE INDEX "payment_order_exports_batch_sequence_key" ON "payment_order_exports"("payment_batch_id", "sequence"); + +-- Pre-existing loan batches only know how many times they were exported. The +-- summary keeps that known count and states what is unknown; it is never a +-- real export or bank submission and blocks failure confirmation and release. +CREATE TABLE "loan_legacy_export_summaries" ( + "payment_batch_id" UUID NOT NULL, + "known_export_count" INTEGER NOT NULL, + "last_exported_at" TIMESTAMP(3), + "last_exported_by" UUID, + "unknown_facts" VARCHAR(200) NOT NULL DEFAULT 'per_export_detail,file_digest,bank_submission_mapping', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "loan_legacy_export_summaries_pkey" PRIMARY KEY ("payment_batch_id"), + CONSTRAINT "loan_legacy_export_summaries_count_check" CHECK ("known_export_count" > 0) +); + +CREATE TABLE "loan_bank_submissions" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "export_id" UUID, + "channel" VARCHAR(32) NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL, + "bank_instruction_ref" VARCHAR(120) NOT NULL, + "customer_serial" VARCHAR(64), + "note" VARCHAR(1000) NOT NULL, + "status" VARCHAR(16) NOT NULL DEFAULT 'active', + "withdrawn_reason" VARCHAR(500), + "withdrawn_at" TIMESTAMP(3), + "registered_by" UUID NOT NULL, + "registered_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "loan_bank_submissions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "loan_bank_submissions_channel_check" CHECK ("channel" IN ('online_banking_import', 'manual_entry', 'other')), + CONSTRAINT "loan_bank_submissions_import_export_check" CHECK ("channel" <> 'online_banking_import' OR "export_id" IS NOT NULL), + CONSTRAINT "loan_bank_submissions_ref_check" CHECK (char_length(btrim("bank_instruction_ref")) >= 2 AND char_length(btrim("note")) >= 5), + CONSTRAINT "loan_bank_submissions_status_check" CHECK ( + ("status" = 'active' AND "withdrawn_at" IS NULL AND "withdrawn_reason" IS NULL) + OR ("status" = 'withdrawn' AND "withdrawn_at" IS NOT NULL AND char_length(btrim("withdrawn_reason")) >= 2)) +); +CREATE INDEX "loan_bank_submissions_batch_idx" ON "loan_bank_submissions"("payment_batch_id", "status"); + +-- An independently reviewed statement that the active submissions (and the +-- export records) listed are the complete set for the batch. It also snapshots +-- every submission row including withdrawn ones, so any registration or +-- withdrawal after review invalidates the approval. +CREATE TABLE "loan_bank_submission_attestations" ( + "id" UUID NOT NULL, + "payment_batch_id" UUID NOT NULL, + "submission_ids" JSONB NOT NULL, + "all_submission_ids" JSONB NOT NULL, + "export_ids" JSONB NOT NULL, + "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_submission_attestations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "loan_bank_submission_attestations_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')), + CONSTRAINT "loan_bank_submission_attestations_ids_check" CHECK ( + jsonb_typeof("submission_ids") = 'array' AND jsonb_array_length("submission_ids") > 0 + AND jsonb_typeof("all_submission_ids") = 'array' AND jsonb_typeof("export_ids") = 'array'), + CONSTRAINT "loan_bank_submission_attestations_note_check" CHECK (char_length(btrim("note")) >= 5), + CONSTRAINT "loan_bank_submission_attestations_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_submission_attestations_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)), + CONSTRAINT "loan_bank_submission_attestations_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by") +); +CREATE UNIQUE INDEX "loan_bank_submission_attestations_one_active_per_batch" + ON "loan_bank_submission_attestations"("payment_batch_id") WHERE "status" IN ('proposed', 'approved'); + +-- AddForeignKey +ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_exported_by_fkey" FOREIGN KEY ("exported_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_legacy_export_summaries" ADD CONSTRAINT "loan_legacy_export_summaries_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_export_id_fkey" FOREIGN KEY ("export_id") REFERENCES "payment_order_exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_registered_by_fkey" FOREIGN KEY ("registered_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Backfill: known export counts of existing loan batches become summaries. +INSERT INTO "loan_legacy_export_summaries" ("payment_batch_id", "known_export_count", "last_exported_at", "last_exported_by") +SELECT "id", "payment_order_export_count", "payment_order_last_exported_at", "payment_order_last_exported_by" +FROM "payment_batches" WHERE "entity_type" = 'loan_repayment' AND "payment_order_export_count" > 0; + +-- History guards: exports and summaries are append-only; submissions may only +-- be withdrawn once; attestations follow proposed -> approved/rejected/superseded, +-- approved -> superseded. +CREATE FUNCTION guard_loan_reconciliation_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME; +END $$; +CREATE TRIGGER payment_order_exports_append_only BEFORE UPDATE OR DELETE ON "payment_order_exports" + FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only(); +CREATE TRIGGER loan_legacy_export_summaries_append_only BEFORE UPDATE OR DELETE ON "loan_legacy_export_summaries" + FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only(); + +CREATE FUNCTION guard_loan_bank_submission_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan bank submission history is immutable'; END IF; + IF OLD."status" <> 'active' OR NEW."status" <> 'withdrawn' + OR (NEW."payment_batch_id", NEW."export_id", NEW."channel", NEW."submitted_at", NEW."bank_instruction_ref", + NEW."customer_serial", NEW."note", NEW."registered_by", NEW."registered_at") + IS DISTINCT FROM (OLD."payment_batch_id", OLD."export_id", OLD."channel", OLD."submitted_at", OLD."bank_instruction_ref", + OLD."customer_serial", OLD."note", OLD."registered_by", OLD."registered_at") THEN + RAISE EXCEPTION 'loan bank submission can only be withdrawn once'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER loan_bank_submission_history BEFORE UPDATE OR DELETE ON "loan_bank_submissions" + FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_submission_history(); + +CREATE FUNCTION guard_loan_submission_attestation_history() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan submission attestation history is immutable'; END IF; + IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan submission attestation is immutable'; END IF; + IF (NEW."payment_batch_id", NEW."submission_ids", NEW."all_submission_ids", NEW."export_ids", NEW."note", NEW."proposed_by", NEW."proposed_at") + IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_ids", OLD."all_submission_ids", OLD."export_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN + RAISE EXCEPTION 'loan submission attestation 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 submission attestation 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 submission attestation cannot carry review fields'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER loan_submission_attestation_history BEFORE UPDATE OR DELETE ON "loan_bank_submission_attestations" + FOR EACH ROW EXECUTE FUNCTION guard_loan_submission_attestation_history(); diff --git a/server/prisma/migrations/20261007020000_loan_failure_attribution/migration.sql b/server/prisma/migrations/20261007020000_loan_failure_attribution/migration.sql new file mode 100644 index 00000000..46a718ff --- /dev/null +++ b/server/prisma/migrations/20261007020000_loan_failure_attribution/migration.sql @@ -0,0 +1,45 @@ +-- S2 of the loan bank-result reconciliation design (design.md 3.1): a failed +-- receipt on a batch with same-lender, same-amount competitors needs an +-- independently reviewed attribution that names the bank submissions it covers. + +-- AlterTable +ALTER TABLE "loan_receipt_attributions" + ADD COLUMN "kind" VARCHAR(16) NOT NULL DEFAULT 'settlement', + ADD COLUMN "covered_submission_ids" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_kind_check" CHECK ("kind" IN ('settlement', 'failure')); +ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_covered_check" CHECK ( + jsonb_typeof("covered_submission_ids") = 'array' + AND ("kind" <> 'failure' OR (jsonb_array_length("covered_submission_ids") > 0 AND jsonb_array_length("verification_ids") = 1))); + +ALTER TABLE "loan_receipt_attributions" DROP CONSTRAINT "loan_receipt_attributions_basis_kind_check"; +ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN + ('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved', 'bank_instruction_ref')); + +-- One open/approved attribution per batch and kind. +DROP INDEX "loan_receipt_attributions_one_active_per_batch"; +CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch_kind" + ON "loan_receipt_attributions"("payment_batch_id", "kind") WHERE "status" IN ('proposed', 'approved'); + +-- The proposal (now including kind and covered submissions) stays immutable. +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" THEN + RAISE EXCEPTION 'loan receipt attribution proposal is immutable'; + END IF; + RETURN NEW; +END $$; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 9db16114..543246a9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -737,6 +737,10 @@ model Employee { loan_receipt_attributions_proposed LoanReceiptAttribution[] @relation("LoanReceiptAttributionProposer") loan_receipt_attributions_reviewed LoanReceiptAttribution[] @relation("LoanReceiptAttributionReviewer") + payment_order_exports_made PaymentOrderExport[] + loan_bank_submissions_registered LoanBankSubmission[] + loan_submission_attestations_proposed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationProposer") + loan_submission_attestations_reviewed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationReviewer") @@map("employees") } @@ -1977,6 +1981,10 @@ model PaymentBatch { @@index([payment_account_id], map: "idx_payment_batches_payment_account") @@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by") loan_receipt_attributions LoanReceiptAttribution[] + payment_order_exports PaymentOrderExport[] + loan_legacy_export_summary LoanLegacyExportSummary? + loan_bank_submissions LoanBankSubmission[] + loan_bank_submission_attestations LoanBankSubmissionAttestation[] @@map("payment_batches") } @@ -3994,6 +4002,8 @@ model LoanReceiptAttribution { key_id String @db.VarChar(64) verification_ids Json manual_proposal_ids Json + kind String @default("settlement") @db.VarChar(16) + covered_submission_ids Json @default("[]") competitor_batch_ids Json basis_kind String @db.VarChar(32) basis_note String @db.VarChar(1000) @@ -4013,3 +4023,81 @@ model LoanReceiptAttribution { @@index([payment_batch_id, fingerprint], map: "loan_receipt_attributions_batch_fingerprint_idx") @@map("loan_receipt_attributions") } + +// Loan bank-result reconciliation S1 (migration 20261007010000_loan_bank_submissions). +// Append-only/one-way history triggers and partial unique index live in the migration. +model PaymentOrderExport { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + sequence Int + exported_at DateTime @default(now()) + exported_by String @db.Uuid + file_sha256 String @db.VarChar(64) + customer_serials Json + total_amount Decimal @db.Decimal(14, 2) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + exporter Employee @relation(fields: [exported_by], references: [id], onDelete: Restrict) + submissions LoanBankSubmission[] + + @@unique([payment_batch_id, sequence], map: "payment_order_exports_batch_sequence_key") + @@map("payment_order_exports") +} + +model LoanLegacyExportSummary { + payment_batch_id String @id @db.Uuid + known_export_count Int + last_exported_at DateTime? + last_exported_by String? @db.Uuid + unknown_facts String @default("per_export_detail,file_digest,bank_submission_mapping") @db.VarChar(200) + created_at DateTime @default(now()) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + + @@map("loan_legacy_export_summaries") +} + +model LoanBankSubmission { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + export_id String? @db.Uuid + channel String @db.VarChar(32) + submitted_at DateTime + bank_instruction_ref String @db.VarChar(120) + customer_serial String? @db.VarChar(64) + note String @db.VarChar(1000) + status String @default("active") @db.VarChar(16) + withdrawn_reason String? @db.VarChar(500) + withdrawn_at DateTime? + registered_by String @db.Uuid + registered_at DateTime @default(now()) + + payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict) + export PaymentOrderExport? @relation(fields: [export_id], references: [id], onDelete: Restrict) + registrar Employee @relation(fields: [registered_by], references: [id], onDelete: Restrict) + + @@index([payment_batch_id, status], map: "loan_bank_submissions_batch_idx") + @@map("loan_bank_submissions") +} + +model LoanBankSubmissionAttestation { + id String @id @default(uuid()) @db.Uuid + payment_batch_id String @db.Uuid + submission_ids Json + all_submission_ids Json + export_ids Json + 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) + proposer Employee @relation("LoanSubmissionAttestationProposer", fields: [proposed_by], references: [id], onDelete: Restrict) + reviewer Employee? @relation("LoanSubmissionAttestationReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict) + + @@map("loan_bank_submission_attestations") +} diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 12f52000..0b9d8750 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -22,6 +22,13 @@ const ERROR_REASONS = [ {"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"}, {"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"}, {"code":"loan.receipt_attribution_exists","domain":"loan","audience":"user","defaultMessage":"本还款已有待复核或已通过的回单归属确认,请勿重复提交。","recovery":"follow_guidance"}, + {"code":"loan.submission_required","domain":"loan","audience":"user","defaultMessage":"须先登记本批次实际向银行提交的指令,才能确认提交清单。","recovery":"follow_guidance"}, + {"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。","recovery":"follow_guidance"}, + {"code":"loan.submission_history_unverified","domain":"loan","audience":"user","defaultMessage":"该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。","recovery":"follow_guidance"}, + {"code":"loan.multiple_submissions_unresolved","domain":"loan","audience":"user","defaultMessage":"该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。","recovery":"follow_guidance"}, + {"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":"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 32b39d45..2773a303 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -4,6 +4,8 @@ const { loadPendingPaymentResponsibility } = require("../services/paymentRespons const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure, proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments"); +const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, + reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { ERROR_REASONS } = require("../constants/errorReasons"); const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification"); @@ -1048,7 +1050,8 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = { return { ...(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) }; + return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config), + bankSubmissions: await bankSubmissionState(tx, batch) }; })() } : {}), ...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true, @@ -1494,7 +1497,8 @@ 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({ - basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"]), + kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: 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(); const ATTRIBUTION_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(2).max(500) }).strict(); @@ -1519,6 +1523,33 @@ async function paymentBatchesRoutes(fastify) { const result = await attributionTransaction(request, (tx, batch) => reviewReceiptAttribution(tx, fastify.config, batch, request, attributionId, body)); return sendData(reply, request, { id: result.id, status: result.status }); }); + const SUBMISSION_SCHEMA = z.object({ + exportId: UUID_SCHEMA.optional(), channel: z.enum(["online_banking_import", "manual_entry", "other"]), + submittedAt: z.string().datetime({ offset: true }), bankInstructionRef: z.string().trim().min(2).max(120), + customerSerial: z.string().trim().min(1).max(64).optional(), note: z.string().trim().min(5).max(1000), + }).strict(); + fastify.post("/payment-batches/:id/loan-bank-submissions", { preHandler: authenticateBatch }, async (request, reply) => { + const body = parseBody(SUBMISSION_SCHEMA, request.body); + const result = await attributionTransaction(request, (tx, batch) => registerBankSubmission(tx, batch, request, body)); + return sendData(reply, request, { id: result.id, status: result.status }); + }); + fastify.post("/payment-batches/:id/loan-bank-submissions/:submissionId/withdraw", { preHandler: authenticateBatch }, async (request, reply) => { + const submissionId = parseId(request.params.submissionId); + const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), request.body); + const result = await attributionTransaction(request, (tx, batch) => withdrawBankSubmission(tx, batch, request, submissionId, reason)); + return sendData(reply, request, { id: result.id, status: result.status }); + }); + fastify.post("/payment-batches/:id/loan-submission-attestation", { preHandler: authenticateBatch }, async (request, reply) => { + const { note } = parseBody(z.object({ note: z.string().trim().min(5).max(1000) }).strict(), request.body); + const result = await attributionTransaction(request, (tx, batch) => proposeSubmissionAttestation(tx, batch, request, note)); + return sendData(reply, request, { id: result.id, status: result.status }); + }); + fastify.post("/payment-batches/:id/loan-submission-attestation/:attestationId/review", { preHandler: authenticateBatch }, async (request, reply) => { + const attestationId = parseId(request.params.attestationId); + const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body); + 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/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); @@ -1849,6 +1880,7 @@ async function paymentBatchesRoutes(fastify) { throw new PaymentOrderXlsError(blockers[0].code, { blockers }); } const generated = generatePaymentOrderXls(fastify.config, batch); + if (batch.entity_type === "loan_repayment") await recordLoanPaymentOrderExport(tx, batch, request, generated); const exportCount = Number(batch.payment_order_export_count ?? 0) + 1; const exportedAt = new Date(); await tx.paymentBatch.update({ diff --git a/server/src/services/companyLoanPayments.js b/server/src/services/companyLoanPayments.js index 019c1717..d35bcdd8 100644 --- a/server/src/services/companyLoanPayments.js +++ b/server/src/services/companyLoanPayments.js @@ -169,7 +169,7 @@ function repaymentMatchesInput(repayment, input) { repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount))); } -const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"]; +const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved", "bank_instruction_ref"]; // A matched receipt proves amount/accounts/currency/status, not which loan batch // the bank paid. Any other batch for the same lender and amount that ever reached @@ -192,10 +192,11 @@ const sameIdSet = (stored, current) => { // Valid only for the exact transaction, the exact receipt evidence that was // reviewed (same verifications and manual proposals; a re-scan or re-keyed // receipt of the same transaction invalidates it) and every current competitor. -function attributionMatches(attribution, claim, competitors) { +function attributionMatches(attribution, claim, competitors, coveredSubmissionIds = null) { return Boolean(claim) && attribution.fingerprint === claim.fingerprint && attribution.key_id === claim.keyId && sameIdSet(attribution.verification_ids, claim.verificationIds) && sameIdSet(attribution.manual_proposal_ids, claim.manualProposalIds) && - attributionCovers(attribution, competitors); + attributionCovers(attribution, competitors) && + (attribution.kind !== "failure" || (coveredSubmissionIds !== null && sameIdSet(attribution.covered_submission_ids, coveredSubmissionIds))); } function attributionCovers(attribution, competitors) { @@ -215,7 +216,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) { // stores only one verification) so the approval's evidence binding is checked. const planned = claim && await plannedClaimOrNull(tx, config, batch); const approved = planned && planned.fingerprint === claim.fingerprint && await tx.loanReceiptAttribution.findFirst({ where: { - payment_batch_id: batch.id, status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } }); + payment_batch_id: batch.id, kind: "settlement", status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } }); if (!approved || !attributionMatches(approved, planned, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); } @@ -255,43 +256,85 @@ async function loanReceiptParticipants(tx, batch) { submitterId: repayment.submitted_by }]; } -async function confirmLoanBankFailure(tx, config, batch, request, attachmentId) { - const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks"); +// Exporters, submission registrars and submission-list attesters of the batch. +async function reconciliationParticipants(tx, batch) { + const { submissionFacts } = require("./loanBankSubmissions"); + const facts = await submissionFacts(tx, batch); + const attestations = await tx.loanBankSubmissionAttestation.findMany({ where: { payment_batch_id: batch.id }, select: { proposed_by: true } }); + return [batch.payment_order_last_exported_by, facts.legacy?.last_exported_by, ...facts.exports.map((item) => item.exported_by), + ...facts.submissions.map((item) => item.registered_by), ...attestations.map((item) => item.proposed_by)].filter(Boolean); +} + +// Bank wordings accepted as "no money left the account". 已撤销 (may be before or +// after execution) and 已冲正/已退汇 (paid then reversed/refunded) are excluded: +// they belong to the termination and refund reconciliation flows. +const CLEAR_FAILURE_STATUSES = ["交易失败", "转账失败", "付款失败", "支付失败"]; + +// "Clearly readable" is judged separately from "payment succeeded": the real +// recognizer marks a failed slip needs_review because of its transaction_failed +// issue. A failure fact is a single complete page whose only issue is that +// failure, with every field read and an accepted failure wording. +function isClearBankFailure(recognition) { + if (!["recognized", "needs_review"].includes(recognition?.status) || recognition.pagesExpected !== 1 || + recognition.pagesProcessed !== 1 || recognition.pages?.length !== 1) return false; + const page = recognition.pages[0]; + return page.pageNumber === 1 && page.transactionStatus === "failed" && CLEAR_FAILURE_STATUSES.includes(page.transactionStatusRaw) && + Array.isArray(page.issues) && page.issues.length === 1 && page.issues[0] === "transaction_failed" && + page.amount != null && Boolean(page.currency) && Boolean(page.payerAccount) && Boolean(page.payeeAccount) && Boolean(page.reference); +} + +// The verified failed receipt as a bank fact: a single recognized page that is +// explicitly failed and matches this batch's frozen payer/payee/amount/currency. +async function failedReceiptFact(tx, config, batch, attachmentId) { const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification"); const { matchBankSlip } = require("./paymentReceiptMatching"); - 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`; - const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id }); - // A failed receipt that may belong to a same-lender, same-amount batch cannot - // release this reservation; without an attribution path for failures the - // repayment stays reserved for bank reconciliation. - if ((await competingLoanBatches(tx, batch, repayment)).length) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); }; const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } }); - // More than one bank attempt/evidence is ambiguous: one failed attempt cannot - // establish that another attempt did not pay. Preserve all reservations. - if (await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - if (!attachment || attachment.payment_batch_id !== batch.id || attachment.archived_at || - attachment.governance_status !== "approved" || attachment.uploaded_by === request.employee.id) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; + if (!attachment || attachment.payment_batch_id !== batch.id || attachment.archived_at || attachment.governance_status !== "approved") { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); }; const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }] }); if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)); - if (recognition.status !== "recognized" || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages.length !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + if (!isClearBankFailure(recognition)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; const page = recognition.pages[0]; - if (page.transactionStatus !== "failed" || page.pageNumber !== 1 || !page.reference) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - // A confirmed bank failure still needs the exact payer/payee/amount/currency. // Reusing matching here never turns this evidence into a success verification. const match = matchBankSlip({ ...page, transactionStatus: "success", issues: (page.issues ?? []).filter((v) => v !== "transaction_failed") }, batchCandidates(config, batch)); if (match.status !== "matched") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; const { fingerprint, key_id: keyId } = transactionFingerprint(config, page); - if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) || - await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; - if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint }, { item_id: batch.items[0].id }] } }) || - await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); }; + return { attachment, verification, fingerprint, keyId, verificationIds: [verification.id], manualProposalIds: [] }; +} + +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); + // 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 tx.loanRepayment.update({ where: { id: repayment.id }, data: { - bank_result_state: "confirmed_failed", bank_result_ref: `verification:${verification.id}`, + bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`, } }); - await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id }); + await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds }); } async function plannedLoanClaim(tx, config, batch) { @@ -318,16 +361,30 @@ async function assertAttributableBatch(tx, batch, { requireCompetitors = true } // Finance documents why the verified bank transaction belongs to this batch and // why each competitor is excluded. Time alignment alone is not an accepted basis. +// 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) { + if (kind === "failure") { + const { assertSingleAttestedSubmission } = 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 }; + } + return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null }; +} + async function proposeReceiptAttribution(tx, config, batch, request, input) { - if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); }; + 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 = await plannedLoanClaim(tx, config, batch); + const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, kind, input.attachmentId); // 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, status: { in: ["proposed", "approved"] } } }); + const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, status: { in: ["proposed", "approved"] } } }); if (active) { - if (attributionMatches(active, claim, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); }; + 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() } }); await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: active.id, reason: active.fingerprint !== claim.fingerprint || active.key_id !== claim.keyId ? "receipt_transaction_changed" @@ -336,11 +393,12 @@ 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 ?? [], 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, } }); await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_PROPOSED", batch.loan_repayment_id, { - paymentBatchId: batch.id, attributionId: attribution.id, basisKind: input.basisKind, competitorCount: competitors.length }); + paymentBatchId: batch.id, attributionId: attribution.id, kind, basisKind: input.basisKind, competitorCount: competitors.length }); return attribution; } @@ -351,11 +409,14 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } }); if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); }; const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false }); - const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by]; + const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by, + ...(attribution.kind === "failure" ? await reconciliationParticipants(tx, batch) : [])]; if (input.decision === "approve") { const { competitors } = await assertAttributableBatch(tx, batch); - const claim = await plannedLoanClaim(tx, config, batch); - if (!attributionMatches(attribution, claim, competitors)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); }; + 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); + 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. const verifications = await tx.paymentReceiptVerification.findMany({ where: { id: { in: claim.verificationIds } }, include: { attachment: true } }); @@ -393,14 +454,14 @@ async function loanAttributionSatisfied(tx, config, batch) { const competitors = await competingLoanBatches(tx, batch, repayment); if (!competitors.length) return true; const claim = await plannedClaimOrNull(tx, config, batch); - const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } }); + const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement", status: "approved" } }); return Boolean(approved && attributionMatches(approved, claim, competitors)); } async function loanReceiptAttributionState(tx, batch, repayment, config) { const competitors = await competingLoanBatches(tx, batch, repayment); const planned = competitors.length && config ? await displayPlannedClaim(tx, config, batch) : { claim: null, known: false }; - const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id }, + const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement" }, orderBy: [{ proposed_at: "desc" }, { id: "desc" }], include: { proposer: { select: { name: true } }, reviewer: { select: { name: true } } } }); return { required: competitors.length > 0, @@ -430,4 +491,4 @@ function mapRepayment(repayment, batch) { module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput, eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure, - proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied }; + proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES }; diff --git a/server/src/services/loanBankSubmissions.js b/server/src/services/loanBankSubmissions.js new file mode 100644 index 00000000..0d9716bf --- /dev/null +++ b/server/src/services/loanBankSubmissions.js @@ -0,0 +1,227 @@ +const { createHash } = require("node:crypto"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { decryptStringOrThrow } = require("./crypto"); +const { auditLoan } = require("./companyLoans"); + +// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1). +// Payment-order exports and bank submissions are separate facts: one export +// file may be submitted zero, one or several times, and re-exports reuse the +// same customer serial. Failure/termination evidence must cover the reviewed +// submission list, never the export count or the attachment count. + +const SUBMISSION_CHANNELS = ["online_banking_import", "manual_entry", "other"]; + +function loanPayments() { + // Lazy: companyLoanPayments requires this module for failure confirmation. + return require("./companyLoanPayments"); +} + +async function recordLoanPaymentOrderExport(tx, batch, request, generated) { + const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } }); + return tx.paymentOrderExport.create({ data: { + payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id, + file_sha256: createHash("sha256").update(generated.buffer).digest("hex"), + customer_serials: generated.serials, total_amount: generated.totalAmount, + } }); +} + +function assertSubmissionBatch(batch) { + if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { + throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + } +} + +async function registerBankSubmission(tx, batch, request, input) { + assertSubmissionBatch(batch); + if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + if (input.exportId) { + const record = await tx.paymentOrderExport.findUnique({ where: { id: input.exportId } }); + if (!record || record.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409); + } else if (input.channel === "online_banking_import") { + throw buildBusinessValidationError("loan.submission_export_invalid", {}, 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, + customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id, + } }); + await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, { + paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel }); + return submission; +} + +async function withdrawBankSubmission(tx, batch, request, submissionId, reason) { + assertSubmissionBatch(batch); + const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } }); + if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") { + throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + } + const withdrawn = await tx.loanBankSubmission.update({ where: { id: submission.id }, data: { + status: "withdrawn", withdrawn_reason: reason, withdrawn_at: new Date() } }); + await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_WITHDRAWN", batch.loan_repayment_id, { + paymentBatchId: batch.id, submissionId: submission.id }); + return withdrawn; +} + +async function submissionFacts(tx, batch) { + const [exports, legacy, submissions] = await Promise.all([ + tx.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "asc" } }), + tx.loanLegacyExportSummary.findUnique({ where: { payment_batch_id: batch.id } }), + tx.loanBankSubmission.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ submitted_at: "asc" }, { id: "asc" }] }), + ]); + const active = submissions.filter((item) => item.status === "active"); + return { exports, legacy, submissions, active, + activeIds: active.map((item) => item.id).sort(), allIds: submissions.map((item) => item.id).sort(), + exportIds: exports.map((item) => item.id).sort() }; +} + +const sameIds = (stored, current) => { + const left = Array.isArray(stored) ? [...stored].sort() : null; + return Boolean(left) && left.length === current.length && left.every((value, index) => value === current[index]); +}; + +// Bound to every submission row (withdrawn included) and every export: any +// registration, withdrawal or export after review invalidates the approval, +// so an unreviewed add-then-withdraw cannot revive it. +function attestationMatches(attestation, facts) { + return Boolean(attestation) && sameIds(attestation.submission_ids, facts.activeIds) && + sameIds(attestation.all_submission_ids, facts.allIds) && sameIds(attestation.export_ids, facts.exportIds); +} + +async function activeAttestation(tx, batch) { + return tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } }); +} + +// Finance attests that the active submissions are the complete set for the batch. +// A stale open/approved attestation (lists changed) is superseded in the same +// transaction so the batch can always move forward. +async function proposeSubmissionAttestation(tx, batch, request, note) { + assertSubmissionBatch(batch); + const facts = await submissionFacts(tx, batch); + if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409); + const active = await activeAttestation(tx, batch); + if (active) { + if (attestationMatches(active, facts)) throw buildBusinessValidationError("loan.submission_attestation_exists", {}, 409); + await tx.loanBankSubmissionAttestation.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } }); + await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: active.id }); + } + const attestation = await tx.loanBankSubmissionAttestation.create({ data: { + payment_batch_id: batch.id, submission_ids: facts.activeIds, all_submission_ids: facts.allIds, export_ids: facts.exportIds, + note, proposed_by: request.employee.id } }); + await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_PROPOSED", batch.loan_repayment_id, { + paymentBatchId: batch.id, attestationId: attestation.id, submissionCount: facts.activeIds.length }); + return attestation; +} + +// Independent of everyone who produced the export, the submissions or the +// attestation, plus the batch creator, submitter and lender employee. +async function reviewSubmissionAttestation(tx, batch, request, attestationId, input) { + assertSubmissionBatch(batch); + const attestation = await tx.loanBankSubmissionAttestation.findUnique({ where: { id: attestationId } }); + if (!attestation || attestation.payment_batch_id !== batch.id || attestation.status !== "proposed") { + throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); + } + const { readLoanRepayment, activeReviewer } = loanPayments(); + const repayment = await readLoanRepayment(tx, batch); + const facts = await submissionFacts(tx, batch); + const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id, + batch.payment_order_last_exported_by, facts.legacy?.last_exported_by, + ...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)]; + await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true }); + if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409); + const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.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_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED", + batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id }); + return reviewed; +} + +// S1 temporary gate for failure confirmation (until S3 adds per-submission +// terminal evidence): exactly one reviewed submission, at most one export and no +// legacy summary. Returns the attested submission ids for the S2 attribution. +async function assertSingleAttestedSubmission(tx, batch) { + const facts = await submissionFacts(tx, batch); + if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409); + if (facts.exports.length > 1 || facts.active.length > 1) throw buildBusinessValidationError("loan.multiple_submissions_unresolved", {}, 409); + if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); + 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); + } + return facts.activeIds; +} + +// Voiding an attachment changes material use, not bank facts. Fail closed: a +// voided attachment is ignored only if its current-version recognition shows +// either an explicit failure, or a readable transaction that is clearly not +// this batch's payment (amount/accounts differ, no pending or ambiguous status). +// No verification (voided before OCR), unreadable or ambiguous status, a match, +// or a live manual-receipt proposal keeps blocking. +const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"]; + +async function assertVoidedReceiptsResolved(tx, config, batch) { + const { batchCandidates } = require("./paymentReceiptVerification"); + const { matchBankSlip } = require("./paymentReceiptMatching"); + 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(); + } + } +} + +async function bankSubmissionState(tx, batch) { + const facts = await submissionFacts(tx, batch); + const current = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id }, + orderBy: [{ proposed_at: "desc" }, { id: "desc" }] }); + return { + exports: facts.exports.map((item) => ({ id: item.id, sequence: item.sequence, exportedAt: item.exported_at, + exportedBy: item.exported_by, customerSerials: item.customer_serials })), + legacy: facts.legacy ? { knownExportCount: facts.legacy.known_export_count, lastExportedAt: facts.legacy.last_exported_at, + unknownFacts: facts.legacy.unknown_facts.split(",") } : null, + submissions: facts.submissions.map((item) => ({ id: item.id, exportId: item.export_id, channel: item.channel, + submittedAt: item.submitted_at, bankInstructionRef: item.bank_instruction_ref, customerSerial: item.customer_serial, + note: item.note, status: item.status, registeredBy: item.registered_by })), + attestation: current ? { id: current.id, status: current.status, proposedBy: current.proposed_by, reviewedBy: current.reviewed_by, + stale: ["proposed", "approved"].includes(current.status) && !attestationMatches(current, facts) } : null, + }; +} + +module.exports = { + recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation, + reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts, +}; diff --git a/server/src/services/paymentReceiptMatching.js b/server/src/services/paymentReceiptMatching.js index 0c810ed4..b8888914 100644 --- a/server/src/services/paymentReceiptMatching.js +++ b/server/src/services/paymentReceiptMatching.js @@ -41,7 +41,7 @@ function normalizeStatus(value) { function normalizeBankSlip(fields) { const result = { amount: null, currency: null, payerAccount: null, payeeAccount: null, - transactionStatus: null, reference: null, issues: [], + transactionStatus: null, transactionStatusRaw: null, reference: null, issues: [], }; if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) { result.issues.push("response_invalid"); @@ -68,6 +68,15 @@ function normalizeBankSlip(fields) { result.issues.push(`${key}_ambiguous`); } else if (values.length && values[0] !== null) { result[key] = values[0]; + // The exact bank wording is kept: several distinct bank states share the + // normalized "failed", but only some prove that no money left the account. + // Several bank wordings normalize to the same value (e.g. 交易失败 and 已撤销 + // both become "failed"), so the raw wording is kept only when every status + // field carries the identical text; otherwise it stays null (fail closed). + if (key === "transactionStatus") { + const raws = new Set(entries.map((field) => normalizeText(field.Value))); + result.transactionStatusRaw = raws.size === 1 ? [...raws][0] : null; + } } else { result.issues.push(`${key}_unresolved`); } diff --git a/server/src/services/paymentReceiptVerification.js b/server/src/services/paymentReceiptVerification.js index 5ca2746f..315fae81 100644 --- a/server/src/services/paymentReceiptVerification.js +++ b/server/src/services/paymentReceiptVerification.js @@ -78,7 +78,7 @@ function buildVerificationData(config, batch, attachment, recognition) { status: recognition.status, pagesExpected: recognition.pagesExpected, pagesProcessed: recognition.pagesProcessed, reason: recognition.reason, pages: pages.map((page) => Object.fromEntries( - ["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference", "issues"] + ["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues"] .map((key) => [key, page[key]]), )), })), diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index e5c44974..03a65af8 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -20,6 +20,13 @@ "loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。", "loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。", "loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。", + "loan.submission_required": "须先登记本批次实际向银行提交的指令,才能确认提交清单。", + "loan.submission_list_unverified": "银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。", + "loan.submission_history_unverified": "该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。", + "loan.multiple_submissions_unresolved": "该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。", + "loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。", + "loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。", + "loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。", "loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。", "loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。", "config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",