const { createHash } = require("node:crypto"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { decryptStringOrThrow } = require("./crypto"); const { auditLoan } = require("./companyLoans"); const approvalPolicy = require("./loanApprovalPolicy"); // 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) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); 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) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); 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); } // Stored normalized so terminal evidence and slips can be bound to it; a // bank instruction may belong to only one loan batch. const { normalizeInstructionRef } = require("./loanBankTerminalFacts"); const instructionRef = normalizeInstructionRef(input.bankInstructionRef); if (instructionRef.length < 2) throw buildBusinessValidationError("loan.invalid_input", {}, 400); // Same batch: no second active row with this reference. Other batches: no row // in any status, so a withdrawn instruction cannot be re-registered elsewhere. if (await tx.loanBankSubmission.count({ where: { bank_instruction_ref: instructionRef, OR: [{ payment_batch_id: batch.id, status: "active" }, { payment_batch_id: { not: batch.id } }] } })) { throw buildBusinessValidationError("loan.instruction_ref_conflict", {}, 409); } const submission = await tx.loanBankSubmission.create({ data: { payment_batch_id: batch.id, export_id: input.exportId ?? null, channel: input.channel, submitted_at: new Date(input.submittedAt), bank_instruction_ref: instructionRef, customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id, } }); // A registered submission means the instruction may have reached the bank: // the repayment can no longer be treated as never sent. const { readLoanRepayment } = loanPayments(); const repayment = await readLoanRepayment(tx, batch); if (repayment.bank_result_state === "not_sent") await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } }); await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, { paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel }); return submission; } async function withdrawBankSubmission(tx, batch, request, submissionId, reason) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); 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) { await approvalPolicy.assertMaker(tx, batch, request.employee.id); assertSubmissionBatch(batch); const facts = await submissionFacts(tx, batch); if (!facts.active.length && (!facts.exports.length || facts.submissions.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, ...approvalPolicy.payeeConflicts(repayment), 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, batch }); if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409); const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: { ...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attestation.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_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED", batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id, ...approvalPolicy.auditContext(reviewed) }); 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); await approvalPolicy.assertDecision(tx, batch, approved); const [submission] = facts.active; // The single export (if any) must be the one this submission imported. if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409); return facts.activeIds; } // 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"]; // Per-attachment form of the voided-receipt rule (no database access): the // attachment must include its verifications (newest first) and manual proposals. function voidedAttachmentResolved(config, batch, attachment) { const { batchCandidates } = require("./paymentReceiptVerification"); const { matchBankSlip } = require("./paymentReceiptMatching"); const { isClearBankFailure } = loanPayments(); if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) return false; if (attachment.verifications.some((verification) => verification.status === "matched")) return false; const latest = attachment.verifications[0]; if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) return false; const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted)); const pages = recognition.pages ?? []; if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) || recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) return false; const candidates = batchCandidates(config, batch); const digits = (value) => String(value ?? "").replace(/\D/g, ""); for (const page of pages) { if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) return false; // A failed page counts only under the same strict rule as failure confirmation, // applied page by page: a failed page proves no payment on that page, so a // multi-page voided slip passes only if every page is a clear failure or a // readable, clearly different payment. if (page.transactionStatus === "failed") { if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue; return false; } // Any other page must be fully readable (the recognizer reports no issue). if ((page.issues ?? []).length) return false; if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) return false; // "Clearly another payment": a definite mismatch AND at least one account // differs from every candidate. Same accounts with a different amount may // be a misread of this very payment, so it keeps blocking. if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") return false; if (candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) && digits(candidate.payeeAccount) === digits(page.payeeAccount))) return false; } return true; } async function assertVoidedReceiptsResolved(tx, config, batch) { const voided = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: { not: null } }, include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } }); for (const attachment of voided) { if (!voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409); } } 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, attestationMatches, voidedAttachmentResolved, };