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