借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)

新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
2026-10-07 21:58:03 +08:00
父节点 2e00d30fd0
当前提交 431c36eae3
共修改 32 个文件,包含 1902 行新增和 25 行删除
+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"},
+86 -5
查看文件
@@ -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) } : {}),
},
}),
});
+10 -3
查看文件
@@ -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:/, "");
+2 -2
查看文件
@@ -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 {
+213
查看文件
@@ -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 };
+1 -1
查看文件
@@ -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);
+10 -8
查看文件
@@ -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 });
+45
查看文件
@@ -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 };
+130
查看文件
@@ -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 };
+1 -1
查看文件
@@ -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 }] },
});