const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { decryptStringOrThrow } = require("./crypto"); const { auditLoan } = require("./companyLoans"); const approvalPolicy = require("./loanApprovalPolicy"); // 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 (fact.status === "approved") await approvalPolicy.assertDecision(tx, batch, fact); 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 || evidence.evidence_kind !== "bank_terminal") 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); await approvalPolicy.assertDecision(tx, batch, approved); } } 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) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); const { readLoanRepayment } = loanPayments(); const repayment = await readLoanRepayment(tx, batch); assertTerminalBatch(batch, repayment); if (repayment.bank_result_state !== "unknown") await require("./loanReleaseRecoveries").assertRecoveryCollecting(tx, batch); 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, ...approvalPolicy.payeeConflicts(repayment), ...(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); const excluded = await terminalParticipants(tx, batch, repayment); await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch }); 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: { ...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: fact.id, conflictIds: excluded }), 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, ...approvalPolicy.auditContext(reviewed) }); 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, { manualExportIds = [] } = {}) { 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 && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); await approvalPolicy.assertDecision(tx, batch, approvedList); 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), ...manualExportIds]); 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); const excluded = await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)); await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch }); const usedFacts = await assertNotPaidReleasable(tx, config, batch); const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: { ...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id, conflictIds: excluded }), 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, ...approvalPolicy.auditContext(decided) }); } 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, evidenceKind: item.evidence_kind, 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, note: item.note, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })), }; } async function uploadTerminalEvidence(tx, batch, request, file) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); const { readLoanRepayment } = loanPayments(); if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch); else 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, evidence_kind: file.evidenceKind ?? "bank_terminal", 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) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); // 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); if (await tx.loanBankResultEvent.count({ where: { OR: [{ evidence_id: evidence.id, status: { in: ["approved", "booked"] } }, { correction_evidence_id: evidence.id, status: "dismissed" }] } })) throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 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, };