公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
296 行
21 KiB
JavaScript
296 行
21 KiB
JavaScript
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,
|
|
};
|