借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -1,4 +1,7 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.lender_frozen", "domain": "loan", "audience": "user", "defaultMessage": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.not_submitted_invalid", "domain": "loan", "audience": "user", "defaultMessage": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "recovery": "follow_guidance"},
|
||||
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -149,6 +149,7 @@ function mapManualReceiptError(error) {
|
||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||
mode: z.enum(["download", "preview"]).default("download"),
|
||||
reason: z.string().max(500).optional(),
|
||||
purpose: z.literal("loan_reconciliation").optional(),
|
||||
}).strict();
|
||||
|
||||
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||
@@ -1054,7 +1055,15 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch) };
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch),
|
||||
notSubmitted: await require("../services/loanNotSubmitted").state(tx, batch),
|
||||
failureAttributions: await Promise.all((await tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id, kind: "failure" }, orderBy: { proposed_at: "desc" } })).map(async (a) => ({
|
||||
id: a.id, status: a.status, proposedBy: a.proposed_by, basisNote: a.basis_note, exclusionNote: a.exclusion_note,
|
||||
coveredSubmissionIds: a.covered_submission_ids, failureScope: a.failure_scope,
|
||||
receiptAttachmentIds: [...new Set((await tx.paymentReceiptVerification.findMany({ where: { id: { in: a.verification_ids } }, select: { attachment_id: true } })).map((v) => v.attachment_id))],
|
||||
}))),
|
||||
bankResults: await require("../services/loanBankResults").state(tx, batch),
|
||||
freeze: await require("../services/loanFreezes").state(tx, repayment.lender_id) };
|
||||
})() } : {}),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
@@ -1586,7 +1595,61 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.withdrawRecovery(tx, batch, request, id, reason));
|
||||
return sendData(reply, request, recoveryResponse(result));
|
||||
});
|
||||
const BANK_RESULT_SCHEMA = z.object({
|
||||
kind: z.enum(["late_success", "return"]), parentId: UUID_SCHEMA.optional(), evidenceId: UUID_SCHEMA, requestKey: UUID_SCHEMA,
|
||||
fields: z.array(z.object({ Name: z.string().max(50), Value: z.string().max(200) }).strict()).min(6).max(20),
|
||||
attributionNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
const BANK_RESULT_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(5).max(1000),
|
||||
correctionEvidenceId: UUID_SCHEMA.optional(), correctionKind: z.enum(["misassociated", "duplicate"]).optional() }).strict();
|
||||
fastify.get("/payment-batches/:id/loan-bank-results/:eventId/details", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
let reason = ""; try { reason = decodeURIComponent(String(request.headers["x-access-reason"] ?? "")); } catch { /* invalid reason */ }
|
||||
parseBody(z.object({ reason: z.string().trim().min(5).max(500) }).strict(), { reason });
|
||||
const event = await fastify.prisma.loanBankResultEvent.findUnique({ where: { id: parseId(request.params.eventId) } });
|
||||
if (!event || event.payment_batch_id !== parseId(request.params.id)) throw buildNotFoundError();
|
||||
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LOAN_BANK_RESULT_DETAILS_VIEWED", targetType: "payment_batch", targetId: event.payment_batch_id, extra: { eventId: event.id, reason } }) });
|
||||
reply.header("cache-control", "no-store");
|
||||
return sendData(reply, request, JSON.parse(decryptStringOrThrow(fastify.config, event.payload_encrypted)).input);
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_REVIEW_SCHEMA, request.body); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/apply", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
parseBody(z.object({}).strict(), request.body ?? {}); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").apply(tx, fastify.config, batch, request, id));
|
||||
return sendData(reply, request, { id: result.id, status: result.status, conflict: Boolean(result.conflict) });
|
||||
});
|
||||
const NOT_SUBMITTED_SCHEMA = z.object({
|
||||
exportIds: z.array(UUID_SCHEMA).min(1).max(100), queryEvidenceId: UUID_SCHEMA, statementEvidenceId: UUID_SCHEMA,
|
||||
exporterStatements: z.array(z.object({ exporterId: UUID_SCHEMA, evidenceId: UUID_SCHEMA }).strict()).min(1).max(100),
|
||||
coverageFrom: z.string().datetime({ offset: true }), coverageThrough: z.string().datetime({ offset: true }),
|
||||
note: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(NOT_SUBMITTED_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.get("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const batch = await loadBatchWithItems(fastify.prisma, parseId(request.params.id));
|
||||
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
return sendData(reply, request, await require("../services/loanNotSubmitted").state(fastify.prisma, batch));
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted/:proposalId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
|
||||
const id = parseId(request.params.proposalId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const { kind: evidenceKind } = parseBody(z.object({ kind: z.enum(["bank_terminal", "query_no_result", "account_statement", "exporter_statement", "late_success", "incoming_return"]).default("bank_terminal") }).strict(), request.query);
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: 10 * 1024 * 1024 } })) {
|
||||
@@ -1604,7 +1667,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const storagePath = await writeBuffer({ config: fastify.config, key: `loan-terminal-evidence/${parseId(request.params.id)}/${crypto.randomUUID()}${file.extension}`,
|
||||
buffer: file.buffer, contentType: file.mimeType });
|
||||
try {
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath }));
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath, evidenceKind }));
|
||||
return sendData(reply, request, { id: result.id });
|
||||
} catch (error) {
|
||||
try { await deleteObject({ config: fastify.config, storagePath }); } catch { request.log.error("loan terminal evidence cleanup failed"); }
|
||||
@@ -1993,7 +2056,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
|
||||
// No new payment order once failure / non-payment was concluded.
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
downloadContext.purpose = "loan_principal_repayment";
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
@@ -2220,7 +2283,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
// After a failure / not-paid conclusion a late receipt is still recorded
|
||||
// (state unchanged) so the final release re-check sees and blocks on it.
|
||||
if (!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) {
|
||||
if (!["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
}
|
||||
@@ -2326,6 +2389,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2336,7 +2400,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!attachment) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at) {
|
||||
const reconciliationRead = queryParsed.data.purpose === "loan_reconciliation";
|
||||
if (reconciliationRead) {
|
||||
if (previewMode || batch.entity_type !== "loan_repayment" || !sanitizeDownloadReason(queryParsed.data.reason)) {
|
||||
throw buildBusinessValidationError("payment_batch.invalid_input", {}, 400);
|
||||
}
|
||||
const facts = await fastify.prisma.loanBankTerminalFact.findMany({ where: { payment_batch_id: batchId },
|
||||
select: { receipt_attachment_id: true, resolves_attachment_ids: true } });
|
||||
const attributions = await fastify.prisma.loanReceiptAttribution.findMany({ where: { payment_batch_id: batchId, kind: "failure" }, select: { verification_ids: true } });
|
||||
const linkedVerification = await fastify.prisma.paymentReceiptVerification.count({ where: {
|
||||
attachment_id: attachmentId, id: { in: attributions.flatMap((a) => a.verification_ids) } } });
|
||||
if (!linkedVerification && !facts.some((f) => f.receipt_attachment_id === attachmentId || f.resolves_attachment_ids.includes(attachmentId))) throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at && !reconciliationRead) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_voided", { receiptAttachment: "voided" }, 409);
|
||||
}
|
||||
const downloadReason = previewMode
|
||||
@@ -2410,6 +2486,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (reconciliationRead && (storedBuffer.length !== attachment.file_size || crypto.createHash("sha256").update(storedBuffer).digest("hex") !== attachment.file_sha256)) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = await validateAndNormalizePurchaseAttachment({
|
||||
@@ -2459,6 +2539,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
...(reconciliationRead ? { purpose: "loan_reconciliation", archived: Boolean(attachment.archived_at) } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
repayment = await readLoanRepayment(tx, batch);
|
||||
}
|
||||
const lender = repayment.lender;
|
||||
if (!postPayment) await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
if (postPayment) assertLenderIdentityVerified(lender);
|
||||
else assertLenderReady(lender);
|
||||
if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
@@ -112,6 +113,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
// waits on a previously existing batch. Lender/loan maintenance never locks a batch.
|
||||
async function prepareLoanRepayment(tx, config, request, input) {
|
||||
const lender = await lockLender(tx, input.lenderId);
|
||||
await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
assertLenderReady(lender);
|
||||
if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
|
||||
const ids = input.allocations.map((a) => a.loanId).sort();
|
||||
@@ -225,7 +227,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
|
||||
|
||||
async function settleLoanRepayment(tx, batch, request, config) {
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
await assertReceiptAttributed(tx, config, batch, repayment);
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
||||
@@ -239,10 +241,13 @@ async function settleLoanRepayment(tx, batch, request, config) {
|
||||
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
|
||||
// Stale identity/account must not prevent cancelling a never-sent request.
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 ||
|
||||
await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
if (await tx.loanBankResultEvent.count({ where: { payment_batch_id: batch.id, kind: "late_success", status: { in: ["proposed", "approved"] } } })) {
|
||||
throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409);
|
||||
}
|
||||
// "Never sent" must really mean nothing reached the bank side of the record.
|
||||
if (repayment.bank_result_state === "not_sent" && (
|
||||
await tx.loanBankSubmission.count({ where: { payment_batch_id: batch.id } }) ||
|
||||
@@ -251,7 +256,9 @@ async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryI
|
||||
// Release re-validates the conclusion inside this transaction (batch lock held
|
||||
// by the caller serializes it with new submissions and receipt uploads).
|
||||
const recovered = await require("./loanReleaseRecoveries").consumeRecovery(tx, config, batch, request, recoveryInput);
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_submitted") {
|
||||
await require("./loanNotSubmitted").assertReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_failed") {
|
||||
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
|
||||
|
||||
@@ -5,7 +5,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
|
||||
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
|
||||
|
||||
function ensureLoanManager(fastify, request) {
|
||||
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
|
||||
@@ -26,7 +26,7 @@ async function auditLoan(tx, request, action, targetId, extra = {}) {
|
||||
function loanBalances(loan) {
|
||||
const sum = (state) => (loan.allocations ?? []).filter((a) => a.reservation_state === state)
|
||||
.reduce((total, a) => total.plus(a.amount), new Prisma.Decimal(0));
|
||||
const settled = sum("settled");
|
||||
const settled = (loan.supplemental_settlements ?? []).reduce((total, row) => total.plus(row.settled_amount), sum("settled"));
|
||||
const reserved = sum("reserved");
|
||||
const outstanding = new Prisma.Decimal(loan.opening_outstanding_principal).minus(settled);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
|
||||
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { auditLoan, loanBalances, LOAN_INCLUDE } = require("./companyLoans");
|
||||
const { digest } = require("./loanReleaseRecoveries");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const freezes = () => require("./loanFreezes");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409); };
|
||||
const TERMINAL = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"];
|
||||
|
||||
async function source(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (!TERMINAL.includes(repayment.bank_result_state) || !["reviewed", "failed", "cancelled"].includes(batch.status)) fail();
|
||||
// Bank facts use the frozen approval and account snapshots, not current
|
||||
// employment or subsequently maintained lender/bank master data.
|
||||
if (!batch.reviewed_by || !batch.reviewed_at || [batch.created_by, repayment.submitted_by].includes(batch.reviewed_by) ||
|
||||
repayment.legal_entity_id !== batch.legal_entity_id || !repayment.amount.eq(batch.items[0].amount)) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function evidence(tx, config, batch, id, kind) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path }); const hash = createHash("sha256"); let size = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); size += chunk.length; }
|
||||
if (size !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
return file;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const base = await require("./loanBankTerminalFacts").terminalParticipants(tx, batch, repayment);
|
||||
const [events, manual, q6, sr] = await Promise.all([
|
||||
tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } }),
|
||||
tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
]);
|
||||
return [...new Set([...base, ...events.map((e) => e.proposed_by), ...manual.map((p) => p.proposed_by), ...q6.map((p) => p.proposed_by), ...sr.map((p) => p.started_by)].filter(Boolean))];
|
||||
}
|
||||
async function bankSnapshot(tx, batch, file) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
return { evidenceId: file.id, sha: file.file_sha256, kind: file.evidence_kind,
|
||||
payment: digest({ payer: batch.payment_account_snapshot_json, items: batch.items.map((i) => ({ id: i.id, snapshot: i.snapshot_json, amount: String(i.amount) })) }),
|
||||
allocations: repayment.allocations.map((a) => ({ id: a.id, loanId: a.loan_id, amount: String(a.amount) })),
|
||||
competitors: await payments().competingLoanBatches(tx, batch, repayment) };
|
||||
}
|
||||
async function assertTransactionFree(tx, config, event) {
|
||||
const page = JSON.parse(decryptStringOrThrow(config, event.payload_encrypted)).page;
|
||||
const identity = transactionFingerprint(config, page);
|
||||
if (identity.fingerprint !== event.fingerprint || identity.key_id !== event.key_id) fail();
|
||||
const [claims, aliases, events] = await Promise.all([
|
||||
tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.paymentReceiptTransactionAlias.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: event.fingerprint, event_id: { not: event.id } }, { key_id: { not: event.key_id } }] } }),
|
||||
]);
|
||||
if (claims || aliases || events) fail();
|
||||
}
|
||||
async function outstandingReturn(tx, id) {
|
||||
const [settlements, returned] = await Promise.all([
|
||||
tx.loanSupplementalSettlement.findMany({ where: { event_id: id } }),
|
||||
tx.loanBankResultEvent.findMany({ where: { parent_id: id, kind: "return", status: "booked" } }),
|
||||
]);
|
||||
return settlements.reduce((sum, s) => sum.plus(s.receivable_amount), new Prisma.Decimal(0))
|
||||
.minus(returned.reduce((sum, r) => sum.plus(r.amount), new Prisma.Decimal(0)));
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
const existing = await tx.loanBankResultEvent.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
|
||||
if (existing) {
|
||||
const saved = JSON.parse(decryptStringOrThrow(config, existing.payload_encrypted));
|
||||
if (existing.proposed_by !== request.employee.id || digest(saved.input) !== digest(input)) fail();
|
||||
return existing;
|
||||
}
|
||||
await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, input.evidenceId, input.kind === "return" ? "incoming_return" : "late_success");
|
||||
const page = normalizeBankSlip(input.fields);
|
||||
if (page.issues.length || page.transactionStatus !== "success") fail();
|
||||
const [candidate] = batchCandidates(config, batch);
|
||||
const amount = new Prisma.Decimal(page.amount).div(100);
|
||||
if (!amount.gt(0)) fail();
|
||||
if (input.kind === "late_success") {
|
||||
if (input.parentId || matchBankSlip(page, [candidate]).status !== "matched") fail();
|
||||
} else {
|
||||
const parent = input.parentId && await tx.loanBankResultEvent.findUnique({ where: { id: input.parentId } });
|
||||
if (!parent || parent.payment_batch_id !== batch.id || parent.kind !== "late_success" || parent.status !== "booked" ||
|
||||
matchBankSlip(page, [{ ...candidate, amount: amount.toFixed(2), payerAccount: candidate.payeeAccount, payeeAccount: candidate.payerAccount }]).status !== "matched" ||
|
||||
amount.gt(await outstandingReturn(tx, parent.id))) fail();
|
||||
}
|
||||
const identity = transactionFingerprint(config, page);
|
||||
const previous = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id, fingerprint: identity.fingerprint,
|
||||
key_id: identity.key_id, kind: input.kind, parent_id: input.parentId ?? null, status: { in: ["proposed", "approved"] } }, orderBy: [{ proposed_at: "desc" }, { id: "desc" }] });
|
||||
const event = await tx.loanBankResultEvent.create({ data: { payment_batch_id: batch.id, kind: input.kind,
|
||||
parent_id: input.parentId ?? null, evidence_id: file.id, request_key: input.requestKey, proposed_by: request.employee.id,
|
||||
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({ page, input })), amount,
|
||||
fingerprint: identity.fingerprint, key_id: identity.key_id, snapshot_json: await bankSnapshot(tx, batch, file) } });
|
||||
if (input.kind === "late_success") await freezes().openReason(tx, batch, request, "late_success", event.id);
|
||||
// Claims are consumed only at final booking, never just by approval. A new
|
||||
// freeze exists before a stale proposed/approved entry is superseded.
|
||||
for (const old of previous) {
|
||||
await tx.loanBankResultEvent.update({ where: { id: old.id }, data: { status: "superseded" } });
|
||||
if (old.kind === "late_success") {
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: old.id } } });
|
||||
if (conflict?.status === "active") {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", event.id);
|
||||
await freezes().closeReason(tx, batch, request, "reservation_conflict", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
await freezes().closeReason(tx, batch, request, "late_success", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: event.id, kind: event.kind });
|
||||
return event;
|
||||
}
|
||||
async function eventForBatch(tx, batch, id) {
|
||||
const event = await tx.loanBankResultEvent.findUnique({ where: { id } });
|
||||
if (!event || event.payment_batch_id !== batch.id) fail();
|
||||
return event;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status !== "proposed") fail();
|
||||
await source(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (input.decision === "approve") {
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if (event.kind === "return" && event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
} else if (event.kind === "late_success") {
|
||||
// Uncertainty is not a dismissal. A correction document and a reviewed
|
||||
// specific reason are mandatory before removing a false-alarm freeze.
|
||||
if (!input.correctionEvidenceId || !["misassociated", "duplicate"].includes(input.correctionKind)) fail();
|
||||
await evidence(tx, config, batch, input.correctionEvidenceId, "bank_terminal");
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : event.kind === "late_success" ? "dismissed" : "rejected";
|
||||
const updated = await tx.loanBankResultEvent.update({ where: { id }, data: { status,
|
||||
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason, correction_evidence_id: input.correctionEvidenceId ?? null } });
|
||||
if (status === "dismissed") {
|
||||
const basis = { action: input.correctionKind, recordId: id, evidenceId: input.correctionEvidenceId };
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, basis);
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, basis);
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Finance applies an already independent bank fact. No exportable object is
|
||||
// created. Original bank outcome stays terminal and the event supersedes it.
|
||||
async function apply(tx, config, batch, request, id) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status === "booked") return event;
|
||||
if (event.status !== "approved") fail();
|
||||
const repayment = await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if ((await participants(tx, batch)).includes(event.reviewed_by)) fail();
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
|
||||
if (event.kind === "return") {
|
||||
if (event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if ((await outstandingReturn(tx, event.parent_id)).eq(0)) await freezes().closeReason(tx, batch, request, "receivable", event.parent_id, { action: "returned_in_full", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_RETURN_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id, parentId: event.parent_id, amount: event.amount.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
const conflicts = await tx.loanRepaymentAllocation.findMany({ where: { loan_id: { in: ids }, repayment_id: { not: repayment.id }, reservation_state: "reserved" } });
|
||||
if (conflicts.length) {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", id);
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_CONFLICT", repayment.id, { eventId: id, allocationIds: conflicts.map((a) => a.id) });
|
||||
return { ...event, conflict: true };
|
||||
}
|
||||
if (repayment.allocations.some((a) => !["reserved", "released"].includes(a.reservation_state))) fail();
|
||||
if (repayment.allocations.some((a) => a.reservation_state === "reserved")) {
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
||||
data: { reservation_state: "released", released_at: new Date(), release_reason: "原结论被晚到成功推翻,改由补记结清" } });
|
||||
await tx.paymentBatchItem.updateMany({ where: { payment_batch_id: batch.id, status: { in: ["pending", "failed"] } }, data: { status: "cancelled" } });
|
||||
await tx.paymentBatch.update({ where: { id: batch.id }, data: { status: "cancelled", cancelled_at: new Date(),
|
||||
cancelled_by: request.employee.id, cancelled_reason: "原结论被晚到成功推翻,改由补记结清", updated_by: request.employee.id } });
|
||||
}
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
let receivable = new Prisma.Decimal(0);
|
||||
for (const allocation of repayment.allocations) {
|
||||
const loan = await tx.companyLoan.findUniqueOrThrow({ where: { id: allocation.loan_id }, include: LOAN_INCLUDE });
|
||||
const outstanding = new Prisma.Decimal(loanBalances(loan).outstanding);
|
||||
const settled = Prisma.Decimal.min(allocation.amount, outstanding);
|
||||
const excess = allocation.amount.minus(settled); receivable = receivable.plus(excess);
|
||||
await tx.loanSupplementalSettlement.create({ data: { event_id: id, loan_id: loan.id,
|
||||
bank_amount: allocation.amount, settled_amount: settled, receivable_amount: excess } });
|
||||
}
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if (receivable.gt(0)) await freezes().openReason(tx, batch, request, "receivable", id);
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, { action: "allocations_resolved", recordId: id });
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, { action: "payment_supplemented", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id,
|
||||
originalState: repayment.bank_result_state, originalRef: repayment.bank_result_ref, bankAmount: event.amount.toFixed(2), receivable: receivable.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const events = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }], include: { settlements: true } });
|
||||
return { events: await Promise.all(events.map(async (e) => ({ id: e.id, kind: e.kind, parentId: e.parent_id, evidenceId: e.evidence_id,
|
||||
status: e.status, amount: e.amount.toFixed(2), proposedBy: e.proposed_by, reviewedBy: e.reviewed_by, reviewNote: e.review_note,
|
||||
outstandingReturn: e.kind === "late_success" && e.status === "booked" ? (await outstandingReturn(tx, e.id)).toFixed(2) : null,
|
||||
settlements: e.settlements.map((s) => ({ loanId: s.loan_id, bankAmount: s.bank_amount.toFixed(2), settledAmount: s.settled_amount.toFixed(2), receivableAmount: s.receivable_amount.toFixed(2) })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, apply, state, source };
|
||||
@@ -114,7 +114,7 @@ async function activeAttestation(tx, batch) {
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
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);
|
||||
|
||||
@@ -59,7 +59,7 @@ 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);
|
||||
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); }
|
||||
@@ -178,12 +178,12 @@ function releaseWaitDays(config) {
|
||||
|
||||
// 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) {
|
||||
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 || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
if ((!facts.active.length && !manualExportIds.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 = [];
|
||||
@@ -196,7 +196,7 @@ async function assertNotPaidReleasable(tx, config, batch) {
|
||||
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));
|
||||
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.
|
||||
@@ -240,22 +240,23 @@ async function terminalState(tx, batch) {
|
||||
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) })),
|
||||
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, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
status: item.status, note: item.note, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
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, storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
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;
|
||||
@@ -268,6 +269,7 @@ async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reas
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { lockLender, auditLoan } = require("./companyLoans");
|
||||
|
||||
// Call under the lender lock for all pre-payment actions. Bank facts never use
|
||||
// this gate. A closed cause cannot mask another active cause.
|
||||
async function assertNotFrozen(tx, lenderId) {
|
||||
if (await tx.loanFreezeReason.count({ where: { lender_id: lenderId, status: "active" } })) {
|
||||
throw buildBusinessValidationError("loan.lender_frozen", {}, 409);
|
||||
}
|
||||
}
|
||||
async function openReason(tx, batch, request, kind, sourceKey) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const existing = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (existing) {
|
||||
if (existing.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
return existing;
|
||||
}
|
||||
const reason = await tx.loanFreezeReason.create({ data: { lender_id: repayment.lender_id, payment_batch_id: batch.id,
|
||||
kind, source_key: sourceKey, created_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_OPENED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind });
|
||||
return reason;
|
||||
}
|
||||
|
||||
// Internal only: S6/S7 call after resolving their source fact in this same
|
||||
// transaction. Never expose a generic close/unfreeze route to users.
|
||||
async function closeReason(tx, batch, request, kind, sourceKey, basis) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const reason = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (!reason || reason.payment_batch_id !== batch.id || !basis?.action || !basis?.recordId) {
|
||||
throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
}
|
||||
if (reason.status === "closed") return reason;
|
||||
const closed = await tx.loanFreezeReason.update({ where: { id: reason.id }, data: { status: "closed",
|
||||
closed_by: request.employee.id, closed_at: new Date(), closure_basis: basis } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_CLOSED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind, basis });
|
||||
return closed;
|
||||
}
|
||||
async function state(tx, lenderId) {
|
||||
const reasons = await tx.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: [{ created_at: "asc" }, { id: "asc" }] });
|
||||
return { frozen: reasons.some((r) => r.status === "active"), reasons: reasons.map((r) => ({ id: r.id, kind: r.kind,
|
||||
sourceKey: r.source_key, paymentBatchId: r.payment_batch_id, status: r.status, closureBasis: r.closure_basis })) };
|
||||
}
|
||||
module.exports = { assertNotFrozen, openReason, closeReason, state };
|
||||
@@ -0,0 +1,130 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const terminal = () => require("./loanBankTerminalFacts");
|
||||
const { snapshot, digest } = require("./loanReleaseRecoveries");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.not_submitted_invalid", {}, 409); };
|
||||
const latest = (tx, batch) => tx.loanNotSubmittedProposal.findFirst({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
|
||||
async function materialSnapshot(tx, batch) {
|
||||
const value = await snapshot(tx, batch);
|
||||
// The conclusion changes only after the second review. It is not evidence.
|
||||
delete value.originalState; delete value.originalRef;
|
||||
return value;
|
||||
}
|
||||
async function unchanged(tx, batch, proposal) {
|
||||
return digest(proposal.snapshot_json) === digest(await materialSnapshot(tx, batch));
|
||||
}
|
||||
async function available(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) ||
|
||||
!["unknown", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
!repayment.allocations.length || repayment.allocations.some((a) => a.reservation_state !== "reserved")) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const proposals = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } });
|
||||
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } });
|
||||
return [...new Set([...(await terminal().terminalParticipants(tx, batch, repayment)),
|
||||
...proposals.map((p) => p.proposed_by), ...manual.map((p) => p.proposed_by)].filter(Boolean))];
|
||||
}
|
||||
|
||||
// All three documentary categories, all unlinked exports, and all exporters.
|
||||
// Dates are asserted by the registrar and independently reviewed, not OCR proof.
|
||||
async function assertEvidence(tx, config, batch, input) {
|
||||
await available(tx, batch);
|
||||
await payments().validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
const { submissionFacts } = require("./loanBankSubmissions");
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy || !facts.exports.length) fail();
|
||||
const linked = new Set(facts.active.map((s) => s.export_id).filter(Boolean));
|
||||
const unlinked = facts.exports.filter((e) => !linked.has(e.id));
|
||||
if (!unlinked.length || digest([...input.exportIds].sort()) !== digest(unlinked.map((e) => e.id).sort())) fail();
|
||||
// A withdrawn submission is not proof that the bank did not receive it.
|
||||
if (facts.submissions.some((s) => s.status !== "active" && (!s.export_id || input.exportIds.includes(s.export_id)))) fail();
|
||||
const first = Math.min(...facts.exports.map((e) => new Date(e.exported_at).getTime()));
|
||||
const from = new Date(input.coverageFrom).getTime(); const through = new Date(input.coverageThrough).getTime();
|
||||
if (!Number.isFinite(from) || !Number.isFinite(through) || from > first || through < new Date(input.registeredAt).getTime() || through > Date.now()) fail();
|
||||
const required = [
|
||||
[input.queryEvidenceId, "query_no_result"], [input.statementEvidenceId, "account_statement"],
|
||||
...input.exporterStatements.map((s) => [s.evidenceId, "exporter_statement"]),
|
||||
];
|
||||
const exporters = [...new Set(unlinked.map((e) => e.exported_by))].sort();
|
||||
if (digest(exporters) !== digest(input.exporterStatements.map((s) => s.exporterId).sort()) ||
|
||||
new Set(required.map(([id]) => id)).size !== required.length) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
for (const [id, kind] of required) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind ||
|
||||
(kind === "query_no_result" && file.mime_type !== "application/pdf")) fail();
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path });
|
||||
const hash = createHash("sha256"); let bytes = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); bytes += chunk.length; }
|
||||
if (bytes !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
}
|
||||
// S3 still validates every real submission, every receipt, the approved list
|
||||
// and the wait period. Only these named exports use the manual exception.
|
||||
await terminal().assertNotPaidReleasable(tx, config, batch, { manualExportIds: input.exportIds });
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
await available(tx, batch);
|
||||
const previous = await latest(tx, batch);
|
||||
if (previous && ["proposed", "approved"].includes(previous.status)) {
|
||||
if (await unchanged(tx, batch, previous)) fail();
|
||||
await tx.loanNotSubmittedProposal.update({ where: { id: previous.id }, data: { status: "superseded" } });
|
||||
}
|
||||
// The coverage end must reach registration (Asia/Shanghai calendar day), without asking
|
||||
// the client to predict the server's millisecond clock.
|
||||
const registeredAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + "T00:00:00.000+08:00";
|
||||
const payload = { ...input, registeredAt };
|
||||
await assertEvidence(tx, config, batch, payload);
|
||||
const proposal = await tx.loanNotSubmittedProposal.create({ data: { payment_batch_id: batch.id,
|
||||
sequence: (previous?.sequence ?? 0) + 1, proposed_by: request.employee.id,
|
||||
payload_json: payload, snapshot_json: await materialSnapshot(tx, batch) } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, proposalId: proposal.id });
|
||||
return proposal;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.id !== id || proposal.status !== "proposed") fail();
|
||||
const repayment = await available(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (proposal.reviews.some((r) => r.reviewed_by === request.employee.id)) fail();
|
||||
if (input.decision === "approve") {
|
||||
if (!await unchanged(tx, batch, proposal)) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
await tx.loanNotSubmittedReview.create({ data: { proposal_id: id, reviewed_by: request.employee.id,
|
||||
decision: input.decision, reason: input.reason } });
|
||||
let status = "proposed";
|
||||
if (input.decision === "reject") status = "rejected";
|
||||
else if (proposal.reviews.length === 1 && proposal.reviews[0].decision === "approve") status = "approved";
|
||||
if (status !== "proposed") await tx.loanNotSubmittedProposal.update({ where: { id }, data: { status } });
|
||||
if (status === "approved" && repayment.bank_result_state === "unknown") {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_not_submitted", bank_result_ref: `not-submitted:${id}` } });
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_REVIEWED", batch.loan_repayment_id,
|
||||
{ paymentBatchId: batch.id, proposalId: id, decision: input.decision, status });
|
||||
return { id, status };
|
||||
}
|
||||
async function assertReleasable(tx, config, batch) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.status !== "approved" || proposal.reviews.length !== 2 ||
|
||||
proposal.reviews.some((r) => r.decision !== "approve") ||
|
||||
new Set(proposal.reviews.map((r) => r.reviewed_by)).size !== 2 || !await unchanged(tx, batch, proposal)) fail();
|
||||
const excluded = await participants(tx, batch);
|
||||
if (proposal.reviews.some((r) => excluded.includes(r.reviewed_by))) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const rows = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
return { history: await Promise.all(rows.map(async (p) => ({ id: p.id, sequence: p.sequence, status: p.status,
|
||||
proposedBy: p.proposed_by, payload: p.payload_json, stale: ["proposed", "approved"].includes(p.status) && !await unchanged(tx, batch, p),
|
||||
reviews: p.reviews.map((r) => ({ reviewedBy: r.reviewed_by, decision: r.decision, reason: r.reason })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, state, assertReleasable };
|
||||
@@ -205,5 +205,5 @@ async function recoveryState(tx, batch) {
|
||||
submittedBy: r.submitted_by, reviewedBy: r.reviewed_by, reviewNote: r.review_note, originalState: r.original_state })) };
|
||||
}
|
||||
|
||||
module.exports = { startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
module.exports = { snapshot, digest, startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
isRecoveryCancelRetry, recoveryState, assertRecoveryCollecting, latestRecovery };
|
||||
|
||||
@@ -139,6 +139,7 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
|
||||
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
|
||||
const match = options[0];
|
||||
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
|
||||
requireFact(await tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: { in: match.aliases } }, { key_id: { not: keyId } }] } }) === 0, "receipt_transaction_reused");
|
||||
const existing = await tx.paymentReceiptClaim.findMany({
|
||||
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户