借款对账 S3:银行终态确认与释放(09-17-company-loan-principal-repayment)
迁移 20261008010000 - 银行终态证据单独成表(不进 OCR、不改付款状态、不计回单),只能作废一次, 作废即令依赖事实失效;复核人可凭理由查看(审计、SHA-256 校验) - 每次银行提交一条终态事实:书面终态证据,或流水号等于指令号的严格失败回单; 同一指令的终态可了结该指令的处理中回单,其他提交不能代替 - 新终态 confirmed_not_paid;释放须清单已复核未变、每次提交有成立的终态事实、 导出均关联提交、回单均有解释、等待期满、无历史汇总 - 最终取消在同一事务内重验(与新提交、晚到回单串行);两条释放路径都检查等待期 - 防挪用:指令号跨批次唯一(含撤回)、失败回单绑定本批次指令、一笔失败交易 只支撑一个批次、同一书面证据不能用于两个批次、登记提交即离开“未发送” 验证:借款 HTTP 57 组、浏览器 7 组、77 迁移;server 4472;四轮同模型只读复核 阻断项均已修复,资金守卫变异测试均变红。 注意:界面未完成,不能单独部署;真实回单待验;补证后的恢复缺口尚未设计。 未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,282 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
|
||||
// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3).
|
||||
// A repayment is released as "not paid" only when every reviewed bank submission
|
||||
// has an independently reviewed terminal fact bound to its instruction reference,
|
||||
// every export is linked to a submission, no payment receipt is left unexplained
|
||||
// and the internal wait period has passed. The same check runs again in the
|
||||
// final cancel transaction, so a stale conclusion can never release principal.
|
||||
|
||||
const TERMINAL_KINDS = ["termination_evidence", "failure_receipt"];
|
||||
const TERMINATION_STATUSES = ["rejected", "revoked", "failed"];
|
||||
const PENDING_STATUSES = ["已受理", "受理成功", "处理中", "待处理"];
|
||||
const DEFAULT_RELEASE_WAIT_DAYS = 3;
|
||||
|
||||
const loanPayments = () => require("./companyLoanPayments");
|
||||
const submissions = () => require("./loanBankSubmissions");
|
||||
|
||||
// Instruction references are compared after NFKC, whitespace removal and upper-casing.
|
||||
function normalizeInstructionRef(value) {
|
||||
return typeof value === "string" ? value.normalize("NFKC").replace(/\s+/g, "").toUpperCase() : "";
|
||||
}
|
||||
|
||||
async function latestRecognition(tx, config, attachment) {
|
||||
const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachment.id },
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }] });
|
||||
if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) return null;
|
||||
return { verification, recognition: JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)) };
|
||||
}
|
||||
|
||||
// A pending slip of this batch's payment: a single complete page whose status is
|
||||
// a pending wording (its only issue being transaction_pending), with every field
|
||||
// read, matching the frozen payer/payee/amount/currency. Returns its reference.
|
||||
async function pendingSlipReference(tx, config, batch, attachment) {
|
||||
const { batchCandidates } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const latest = await latestRecognition(tx, config, attachment);
|
||||
const recognition = latest?.recognition;
|
||||
if (!recognition || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages?.length !== 1) return null;
|
||||
const page = recognition.pages[0];
|
||||
if (page.transactionStatus !== "pending" || !PENDING_STATUSES.includes(page.transactionStatusRaw) ||
|
||||
(page.issues ?? []).length !== 1 || page.issues[0] !== "transaction_pending" || !page.reference) return null;
|
||||
const match = matchBankSlip({ ...page, transactionStatus: "success", issues: [] }, batchCandidates(config, batch));
|
||||
return match.status === "matched" ? normalizeInstructionRef(page.reference) : null;
|
||||
}
|
||||
|
||||
// A strictly clear failed slip of this batch's payment; returns verification and reference.
|
||||
async function failureSlip(tx, config, batch, attachmentId) {
|
||||
const fact = await loanPayments().failedReceiptFact(tx, config, batch, attachmentId);
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, fact.verification.normalized_encrypted));
|
||||
return { ...fact, reference: normalizeInstructionRef(recognition.pages[0].reference) };
|
||||
}
|
||||
|
||||
// Re-validates a terminal fact against the current batch state. Throws the
|
||||
// reason when it no longer holds (submission withdrawn, evidence voided, the
|
||||
// failed slip re-recognized or voided, a resolved pending slip changed).
|
||||
async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (fact.kind === "termination_evidence") {
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
} else {
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, fact.receipt_attachment_id); }
|
||||
catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }
|
||||
if (slip.verification.id !== fact.verification_id || slip.reference !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
// Same checks as the single-submission failure path: the transaction is not
|
||||
// claimed as a payment, uses the current key, backs no other batch, and with
|
||||
// same-amount competitors needs a reviewed failure attribution for exactly
|
||||
// this submission.
|
||||
const { assertFailureFingerprintFree, competingLoanBatches, attributionMatches, readLoanRepayment } = loanPayments();
|
||||
await assertFailureFingerprintFree(tx, batch, slip);
|
||||
const competitors = await competingLoanBatches(tx, batch, await readLoanRepayment(tx, batch));
|
||||
if (competitors.length) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission",
|
||||
status: "approved", covered_submission_ids: { equals: [submission.id] } } });
|
||||
if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
||||
}
|
||||
}
|
||||
for (const attachmentId of fact.resolves_attachment_ids ?? []) {
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||
// Only a pending slip of the same instruction can be settled by this fact.
|
||||
if (!attachment || attachment.payment_batch_id !== batch.id ||
|
||||
await pendingSlipReference(tx, config, batch, attachment) !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTerminalBatch(batch, repayment) {
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) ||
|
||||
repayment.allocations.some((a) => a.reservation_state !== "reserved")) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
}
|
||||
|
||||
async function proposeTerminalFact(tx, config, batch, request, input) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
await supersedeStaleTerminalFacts(tx, config, batch, request);
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } });
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (await tx.loanBankTerminalFact.count({ where: { submission_id: submission.id, status: { in: ["proposed", "approved"] } } })) {
|
||||
throw buildBusinessValidationError("loan.terminal_fact_exists", {}, 409);
|
||||
}
|
||||
const data = { payment_batch_id: batch.id, submission_id: submission.id, kind: input.kind, note: input.note,
|
||||
resolves_attachment_ids: [...new Set(input.resolvesAttachmentIds ?? [])], proposed_by: request.employee.id };
|
||||
if (input.kind === "termination_evidence") {
|
||||
if (!TERMINATION_STATUSES.includes(input.terminalStatus) || !input.evidenceId) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
Object.assign(data, { terminal_status: input.terminalStatus, evidence_id: input.evidenceId });
|
||||
} else {
|
||||
if (!input.receiptAttachmentId) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, input.receiptAttachmentId); }
|
||||
catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }
|
||||
Object.assign(data, { terminal_status: "failed", receipt_attachment_id: input.receiptAttachmentId, verification_id: slip.verification.id });
|
||||
}
|
||||
await assertTerminalFactHolds(tx, config, batch, data, submission);
|
||||
const fact = await tx.loanBankTerminalFact.create({ data });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_FACT_PROPOSED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, factId: fact.id, submissionId: submission.id, kind: input.kind });
|
||||
return fact;
|
||||
}
|
||||
|
||||
// Everyone who produced the bank-side record of this batch, plus the batch
|
||||
// creator, submitter and lender employee: none may review or confirm.
|
||||
async function terminalParticipants(tx, batch, repayment) {
|
||||
const { reconciliationParticipants } = loanPayments();
|
||||
const [facts, evidences] = await Promise.all([
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, select: { proposed_by: true } }),
|
||||
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
|
||||
]);
|
||||
const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } });
|
||||
return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)),
|
||||
...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean);
|
||||
}
|
||||
|
||||
async function reviewTerminalFact(tx, config, batch, request, factId, input) {
|
||||
const { readLoanRepayment, activeReviewer } = loanPayments();
|
||||
const fact = await tx.loanBankTerminalFact.findUnique({ where: { id: factId } });
|
||||
if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true });
|
||||
if (input.decision === "approve") {
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } });
|
||||
await assertTerminalFactHolds(tx, config, batch, fact, submission);
|
||||
if (fact.kind === "failure_receipt") {
|
||||
await loanPayments().recordFailureEvidenceUse(tx, batch, await failureSlip(tx, config, batch, fact.receipt_attachment_id), "terminal_fact");
|
||||
}
|
||||
}
|
||||
const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: {
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason } });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
// Approved facts that no longer hold are closed as superseded so that a fresh
|
||||
// fact can be registered for the same submission.
|
||||
async function supersedeStaleTerminalFacts(tx, config, batch, request) {
|
||||
// (Hoisted: also called after evidence is voided.)
|
||||
const facts = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } },
|
||||
include: { submission: true } });
|
||||
for (const fact of facts) {
|
||||
try { await assertTerminalFactHolds(tx, config, batch, fact, fact.submission); }
|
||||
catch {
|
||||
await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: { status: "superseded", superseded_at: new Date() } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_FACT_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWaitDays(config) {
|
||||
const value = Number(config?.loanReleaseWaitDays);
|
||||
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_RELEASE_WAIT_DAYS;
|
||||
}
|
||||
|
||||
// The full "not paid" release condition, evaluated under the batch lock both
|
||||
// when confirming and again in the final cancel transaction.
|
||||
async function assertNotPaidReleasable(tx, config, batch) {
|
||||
const { submissionFacts, attestationMatches, voidedAttachmentResolved } = submissions();
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!facts.active.length || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const covered = new Set();
|
||||
const usedFacts = [];
|
||||
for (const submission of facts.active) {
|
||||
const fact = terminal.find((item) => item.submission_id === submission.id);
|
||||
if (!fact) throw buildBusinessValidationError("loan.terminal_coverage_incomplete", {}, 409);
|
||||
await assertTerminalFactHolds(tx, config, batch, fact, submission);
|
||||
if (fact.receipt_attachment_id) covered.add(fact.receipt_attachment_id);
|
||||
for (const id of fact.resolves_attachment_ids ?? []) covered.add(id);
|
||||
usedFacts.push(fact.id);
|
||||
}
|
||||
// Every export must be accounted for by a submission that reached a terminal state.
|
||||
const linkedExports = new Set(facts.active.map((item) => item.export_id).filter(Boolean));
|
||||
if (facts.exports.some((item) => !linkedExports.has(item.id))) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
// No payment receipt may stay unexplained: covered by a terminal fact, or a
|
||||
// voided slip that is provably a failure or a clearly different payment.
|
||||
const receipts = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id },
|
||||
include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } });
|
||||
for (const attachment of receipts) {
|
||||
if (covered.has(attachment.id)) continue;
|
||||
if (!attachment.archived_at || !voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.receipt_unresolved_for_release", {}, 409);
|
||||
}
|
||||
await assertReleaseWaitElapsed(tx, config, batch);
|
||||
return usedFacts;
|
||||
}
|
||||
|
||||
// Internal wait after the last payment-order export, required on every release
|
||||
// path (not-paid and single-submission failure), at confirmation and at cancel.
|
||||
async function assertReleaseWaitElapsed(tx, config, batch) {
|
||||
const { submissionFacts } = submissions();
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const lastExport = [batch.payment_order_last_exported_at, facts.legacy?.last_exported_at, ...facts.exports.map((item) => item.exported_at)]
|
||||
.filter(Boolean).map((value) => new Date(value).getTime()).reduce((max, value) => Math.max(max, value), 0);
|
||||
if (lastExport && Date.now() - lastExport < releaseWaitDays(config) * 24 * 60 * 60 * 1000) throw buildBusinessValidationError("loan.release_wait_period", {}, 409);
|
||||
}
|
||||
|
||||
async function confirmNotPaid(tx, config, batch, request) {
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { readLoanRepayment, activeReviewer, validateLoanPayment } = loanPayments();
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
assertTerminalBatch(batch, repayment);
|
||||
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true });
|
||||
const usedFacts = await assertNotPaidReleasable(tx, config, batch);
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts });
|
||||
}
|
||||
|
||||
async function terminalState(tx, batch) {
|
||||
const [evidences, terminal] = await Promise.all([
|
||||
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ uploaded_at: "asc" }, { id: "asc" }] }),
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }] }),
|
||||
]);
|
||||
return {
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })),
|
||||
facts: terminal.map((item) => ({ id: item.id, submissionId: item.submission_id, kind: item.kind, terminalStatus: item.terminal_status,
|
||||
evidenceId: item.evidence_id, receiptAttachmentId: item.receipt_attachment_id, resolvesAttachmentIds: item.resolves_attachment_ids,
|
||||
status: item.status, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
// The same bank letter cannot back terminal facts of two batches.
|
||||
if (await tx.loanBankTerminalEvidence.count({ where: { file_sha256: file.fileSha256, payment_batch_id: { not: batch.id } } })) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
const evidence = await tx.loanBankTerminalEvidence.create({ data: {
|
||||
payment_batch_id: batch.id, storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_UPLOADED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
return evidence;
|
||||
}
|
||||
|
||||
// Voiding evidence invalidates every fact that relied on it.
|
||||
async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) {
|
||||
// After release the evidence is part of the closed record; later facts belong
|
||||
// to the late-result reconciliation flow, not to rewriting this one.
|
||||
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
const voided = await tx.loanBankTerminalEvidence.update({ where: { id: evidence.id }, data: {
|
||||
voided_at: new Date(), voided_by: request.employee.id, void_reason: reason } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_VOIDED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
await supersedeStaleTerminalFacts(tx, config, batch, request);
|
||||
return voided;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadTerminalEvidence, voidTerminalEvidence,
|
||||
normalizeInstructionRef, proposeTerminalFact, reviewTerminalFact, supersedeStaleTerminalFacts, assertNotPaidReleasable,
|
||||
confirmNotPaid, terminalState, terminalParticipants, DEFAULT_RELEASE_WAIT_DAYS, assertReleaseWaitElapsed,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户