借款对账 S3:银行终态确认与释放(09-17-company-loan-principal-repayment)
迁移 20261008010000 - 银行终态证据单独成表(不进 OCR、不改付款状态、不计回单),只能作废一次, 作废即令依赖事实失效;复核人可凭理由查看(审计、SHA-256 校验) - 每次银行提交一条终态事实:书面终态证据,或流水号等于指令号的严格失败回单; 同一指令的终态可了结该指令的处理中回单,其他提交不能代替 - 新终态 confirmed_not_paid;释放须清单已复核未变、每次提交有成立的终态事实、 导出均关联提交、回单均有解释、等待期满、无历史汇总 - 最终取消在同一事务内重验(与新提交、晚到回单串行);两条释放路径都检查等待期 - 防挪用:指令号跨批次唯一(含撤回)、失败回单绑定本批次指令、一笔失败交易 只支撑一个批次、同一书面证据不能用于两个批次、登记提交即离开“未发送” 验证:借款 HTTP 57 组、浏览器 7 组、77 迁移;server 4472;四轮同模型只读复核 阻断项均已修复,资金守卫变异测试均变红。 注意:界面未完成,不能单独部署;真实回单待验;补证后的恢复缺口尚未设计。 未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -179,7 +179,8 @@ async function competingLoanBatches(tx, batch, repayment) {
|
||||
return tx.paymentBatch.findMany({ where: {
|
||||
id: { not: batch.id }, entity_type: "loan_repayment",
|
||||
loan_repayment: { lender_id: repayment.lender_id, amount: repayment.amount },
|
||||
OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }],
|
||||
// Any bank-side trace counts: an export, a receipt or a registered submission.
|
||||
OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }, { loan_bank_submissions: { some: {} } }],
|
||||
}, select: { id: true, batch_no: true, status: true }, orderBy: { id: "asc" } });
|
||||
}
|
||||
|
||||
@@ -224,7 +225,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 (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (["confirmed_failed", "confirmed_not_paid"].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" },
|
||||
@@ -235,13 +236,29 @@ async function settleLoanRepayment(tx, batch, request, config) {
|
||||
await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length });
|
||||
}
|
||||
|
||||
async function cancelLoanRepayment(tx, batch, request, reason) {
|
||||
async function cancelLoanRepayment(tx, batch, request, reason, config) {
|
||||
// Stale identity/account must not prevent cancelling a never-sent request.
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (!["not_sent", "confirmed_failed"].includes(repayment.bank_result_state) ||
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].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); };
|
||||
// "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 } }) ||
|
||||
await tx.loanLegacyExportSummary.count({ where: { payment_batch_id: batch.id } }) ||
|
||||
await tx.loanBankTerminalEvidence.count({ where: { payment_batch_id: batch.id } }))) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
// Release re-validates the conclusion inside this transaction (batch lock held
|
||||
// by the caller serializes it with new submissions and receipt uploads).
|
||||
if (repayment.bank_result_state === "confirmed_not_paid") {
|
||||
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
|
||||
} else if (repayment.bank_result_state === "confirmed_failed") {
|
||||
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
|
||||
const verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } });
|
||||
if (!verification) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
const { fact } = await assertFailureFacts(tx, config, batch, repayment, verification.attachment_id);
|
||||
if (fact.verification.id !== verification.id) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
}
|
||||
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`);
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id }, data: {
|
||||
@@ -302,35 +319,74 @@ async function failedReceiptFact(tx, config, batch, attachmentId) {
|
||||
return { attachment, verification, fingerprint, keyId, verificationIds: [verification.id], manualProposalIds: [] };
|
||||
}
|
||||
|
||||
// The failed slip must be the bank's answer to this batch's own instruction:
|
||||
// its reference equals the attested submission's instruction reference (which
|
||||
// is unique across batches). Otherwise another batch's failure could release it.
|
||||
async function assertFailedSlipIsSubmission(tx, config, batch, fact, submissionIds) {
|
||||
const { normalizeInstructionRef } = require("./loanBankTerminalFacts");
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionIds[0] } });
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, fact.verification.normalized_encrypted));
|
||||
if (submissionIds.length !== 1 || !submission || submission.payment_batch_id !== batch.id ||
|
||||
normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); };
|
||||
}
|
||||
|
||||
// A failed transaction must not be claimed as a payment anywhere, must use the
|
||||
// current receipt key, and may back the release of only one batch.
|
||||
async function assertFailureFingerprintFree(tx, batch, fact) {
|
||||
if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: fact.keyId } } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: fact.keyId } } }) ||
|
||||
await tx.loanFailureEvidenceUse.count({ where: { key_id: { not: fact.keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: fact.fingerprint }, { item_id: batch.items[0].id }] } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: fact.fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
const used = await tx.loanFailureEvidenceUse.findUnique({ where: { fingerprint: fact.fingerprint } });
|
||||
if (used && used.payment_batch_id !== batch.id) { throw buildBusinessValidationError("loan.failure_evidence_reused", {}, 409); };
|
||||
}
|
||||
|
||||
async function recordFailureEvidenceUse(tx, batch, fact, source) {
|
||||
await assertFailureFingerprintFree(tx, batch, fact);
|
||||
if (!await tx.loanFailureEvidenceUse.findUnique({ where: { fingerprint: fact.fingerprint } })) {
|
||||
await tx.loanFailureEvidenceUse.create({ data: { fingerprint: fact.fingerprint, key_id: fact.keyId, payment_batch_id: batch.id, source } });
|
||||
}
|
||||
}
|
||||
|
||||
// Every condition behind a single-submission failure confirmation. Runs when
|
||||
// confirming and again in the final cancel, so release never relies on a stale
|
||||
// conclusion (late receipt, changed submission list, voided or re-read slip).
|
||||
async function assertFailureFacts(tx, config, batch, repayment, attachmentId) {
|
||||
const { assertSingleAttestedSubmission, assertVoidedReceiptsResolved } = require("./loanBankSubmissions");
|
||||
// Only non-voided material counts, but voiding never erases a bank fact.
|
||||
const activeAttachments = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: null }, select: { id: true } });
|
||||
if (activeAttachments.length !== 1 || activeAttachments[0].id !== attachmentId) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
await assertVoidedReceiptsResolved(tx, config, batch);
|
||||
// One failed instruction proves nothing about other submissions.
|
||||
const submissionIds = await assertSingleAttestedSubmission(tx, batch);
|
||||
const fact = await failedReceiptFact(tx, config, batch, attachmentId);
|
||||
await assertFailedSlipIsSubmission(tx, config, batch, fact, submissionIds);
|
||||
await assertFailureFingerprintFree(tx, batch, fact);
|
||||
await require("./loanBankTerminalFacts").assertReleaseWaitElapsed(tx, config, batch);
|
||||
// Same-lender, same-amount competitors: the failed receipt must be attributed
|
||||
// to this batch by a reviewed failure attribution covering the attested list.
|
||||
const competitors = await competingLoanBatches(tx, batch, repayment);
|
||||
if (competitors.length) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "batch", status: "approved",
|
||||
covered_submission_ids: { equals: submissionIds } } });
|
||||
if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
|
||||
}
|
||||
return { fact, submissionIds };
|
||||
}
|
||||
|
||||
async function confirmLoanBankFailure(tx, config, batch, request, attachmentId) {
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { assertSingleAttestedSubmission, assertVoidedReceiptsResolved } = require("./loanBankSubmissions");
|
||||
if (!["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`;
|
||||
// A bank fact after sending: frozen snapshot and approval-time identities;
|
||||
// the confirming reviewer is still judged by current eligibility.
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id, postPayment: true });
|
||||
// Only non-voided material counts, but voiding never erases a bank fact.
|
||||
const activeAttachments = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: null }, select: { id: true } });
|
||||
if (activeAttachments.length !== 1 || activeAttachments[0].id !== attachmentId) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
await assertVoidedReceiptsResolved(tx, config, batch);
|
||||
// One failed instruction proves nothing about other submissions (S1 gate).
|
||||
const submissionIds = await assertSingleAttestedSubmission(tx, batch);
|
||||
const fact = await failedReceiptFact(tx, config, batch, attachmentId);
|
||||
const { fact, submissionIds } = await assertFailureFacts(tx, config, batch, repayment, attachmentId);
|
||||
// Independent of everyone who produced the bank-side evidence for this batch.
|
||||
if ((await reconciliationParticipants(tx, batch)).concat(fact.attachment.uploaded_by).includes(request.employee.id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
|
||||
if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: fact.keyId } } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: fact.keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: fact.fingerprint }, { item_id: batch.items[0].id }] } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: fact.fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
// Same-lender, same-amount competitors: the failed receipt must be attributed
|
||||
// to this batch by a reviewed failure attribution covering the attested list.
|
||||
const competitors = await competingLoanBatches(tx, batch, repayment);
|
||||
if (competitors.length) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", status: "approved" } });
|
||||
if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
|
||||
}
|
||||
await recordFailureEvidenceUse(tx, batch, fact, "failure_confirmation");
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`,
|
||||
} });
|
||||
@@ -364,25 +420,40 @@ async function assertAttributableBatch(tx, batch, { requireCompetitors = true }
|
||||
// The evidence an attribution of the given kind binds to: the transaction the
|
||||
// next execute would claim (settlement), or the verified failed receipt plus
|
||||
// the attested submission list it covers (failure).
|
||||
async function attributionEvidence(tx, config, batch, kind, attachmentId) {
|
||||
async function attributionEvidence(tx, config, batch, kind, attachmentId, submissionId = null) {
|
||||
if (kind === "failure") {
|
||||
const { assertSingleAttestedSubmission } = require("./loanBankSubmissions");
|
||||
const { assertSingleAttestedSubmission, submissionFacts, attestationMatches } = require("./loanBankSubmissions");
|
||||
if (!attachmentId) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
|
||||
const coveredSubmissionIds = await assertSingleAttestedSubmission(tx, batch);
|
||||
return { claim: await failedReceiptFact(tx, config, batch, attachmentId), coveredSubmissionIds };
|
||||
const claim = await failedReceiptFact(tx, config, batch, attachmentId);
|
||||
if (!submissionId) {
|
||||
const coveredSubmissionIds = await assertSingleAttestedSubmission(tx, batch);
|
||||
await assertFailedSlipIsSubmission(tx, config, batch, claim, coveredSubmissionIds);
|
||||
return { claim, coveredSubmissionIds, failureScope: "batch" };
|
||||
}
|
||||
// Per-submission scope (S3): the failed slip must carry this submission's
|
||||
// instruction reference, within a reviewed and unchanged submission list.
|
||||
const { normalizeInstructionRef } = require("./loanBankTerminalFacts");
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const submission = facts.active.find((item) => item.id === submissionId);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!submission || !attestationMatches(approvedList, facts)) { throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); };
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, claim.verification.normalized_encrypted));
|
||||
if (normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); };
|
||||
return { claim, coveredSubmissionIds: [submissionId], failureScope: "submission" };
|
||||
}
|
||||
return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null };
|
||||
return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null, failureScope: "batch" };
|
||||
}
|
||||
|
||||
async function proposeReceiptAttribution(tx, config, batch, request, input) {
|
||||
const kind = input.kind ?? "settlement";
|
||||
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind) || !["settlement", "failure"].includes(kind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
|
||||
const { competitors } = await assertAttributableBatch(tx, batch);
|
||||
const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, kind, input.attachmentId);
|
||||
const { claim, coveredSubmissionIds, failureScope } = await attributionEvidence(tx, config, batch, kind, input.attachmentId, kind === "failure" ? input.submissionId ?? null : null);
|
||||
// A still-valid open/approved record blocks duplicates. One whose receipt
|
||||
// transaction or competitor coverage no longer matches is closed as
|
||||
// superseded in the same transaction so the batch never deadlocks.
|
||||
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, status: { in: ["proposed", "approved"] } } });
|
||||
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, failure_scope: failureScope,
|
||||
status: { in: ["proposed", "approved"] }, covered_submission_ids: { equals: coveredSubmissionIds ?? [] } } });
|
||||
if (active) {
|
||||
if (attributionMatches(active, claim, competitors, coveredSubmissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); };
|
||||
await tx.loanReceiptAttribution.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
|
||||
@@ -393,7 +464,7 @@ async function proposeReceiptAttribution(tx, config, batch, request, input) {
|
||||
const attribution = await tx.loanReceiptAttribution.create({ data: {
|
||||
payment_batch_id: batch.id, fingerprint: claim.fingerprint, key_id: claim.keyId,
|
||||
verification_ids: claim.verificationIds, manual_proposal_ids: claim.manualProposalIds,
|
||||
kind, covered_submission_ids: coveredSubmissionIds ?? [],
|
||||
kind, covered_submission_ids: coveredSubmissionIds ?? [], failure_scope: failureScope,
|
||||
competitor_batch_ids: competitors.map((item) => item.id), basis_kind: input.basisKind,
|
||||
basis_note: input.basisNote, exclusion_note: input.exclusionNote, proposed_by: request.employee.id,
|
||||
} });
|
||||
@@ -415,7 +486,8 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
|
||||
const { competitors } = await assertAttributableBatch(tx, batch);
|
||||
const attachmentId = attribution.kind === "failure"
|
||||
? (await tx.paymentReceiptVerification.findUnique({ where: { id: attribution.verification_ids[0] } }))?.attachment_id : null;
|
||||
const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, attribution.kind, attachmentId);
|
||||
const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, attribution.kind, attachmentId,
|
||||
attribution.kind === "failure" && attribution.failure_scope === "submission" ? attribution.covered_submission_ids[0] : null);
|
||||
if (!attributionMatches(attribution, claim, competitors, coveredSubmissionIds)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
// Also independent of everyone who produced the claimed evidence: uploaders
|
||||
// of every receipt carrying this transaction and any manual-receipt proposer.
|
||||
@@ -491,4 +563,5 @@ function mapRepayment(repayment, batch) {
|
||||
|
||||
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
||||
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES };
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES,
|
||||
failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse };
|
||||
|
||||
@@ -40,11 +40,27 @@ async function registerBankSubmission(tx, batch, request, input) {
|
||||
} else if (input.channel === "online_banking_import") {
|
||||
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
}
|
||||
// Stored normalized so terminal evidence and slips can be bound to it; a
|
||||
// bank instruction may belong to only one loan batch.
|
||||
const { normalizeInstructionRef } = require("./loanBankTerminalFacts");
|
||||
const instructionRef = normalizeInstructionRef(input.bankInstructionRef);
|
||||
if (instructionRef.length < 2) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
// Same batch: no second active row with this reference. Other batches: no row
|
||||
// in any status, so a withdrawn instruction cannot be re-registered elsewhere.
|
||||
if (await tx.loanBankSubmission.count({ where: { bank_instruction_ref: instructionRef,
|
||||
OR: [{ payment_batch_id: batch.id, status: "active" }, { payment_batch_id: { not: batch.id } }] } })) {
|
||||
throw buildBusinessValidationError("loan.instruction_ref_conflict", {}, 409);
|
||||
}
|
||||
const submission = await tx.loanBankSubmission.create({ data: {
|
||||
payment_batch_id: batch.id, export_id: input.exportId ?? null, channel: input.channel,
|
||||
submitted_at: new Date(input.submittedAt), bank_instruction_ref: input.bankInstructionRef,
|
||||
submitted_at: new Date(input.submittedAt), bank_instruction_ref: instructionRef,
|
||||
customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id,
|
||||
} });
|
||||
// A registered submission means the instruction may have reached the bank:
|
||||
// the repayment can no longer be treated as never sent.
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (repayment.bank_result_state === "not_sent") await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel });
|
||||
return submission;
|
||||
@@ -148,9 +164,8 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const [submission] = facts.active;
|
||||
if (facts.exports.length && submission.export_id && submission.export_id !== facts.exports[0].id) {
|
||||
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
}
|
||||
// The single export (if any) must be the one this submission imported.
|
||||
if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
return facts.activeIds;
|
||||
}
|
||||
|
||||
@@ -162,45 +177,50 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
// or a live manual-receipt proposal keeps blocking.
|
||||
const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"];
|
||||
|
||||
async function assertVoidedReceiptsResolved(tx, config, batch) {
|
||||
// Per-attachment form of the voided-receipt rule (no database access): the
|
||||
// attachment must include its verifications (newest first) and manual proposals.
|
||||
function voidedAttachmentResolved(config, batch, attachment) {
|
||||
const { batchCandidates } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { isClearBankFailure } = loanPayments();
|
||||
if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) return false;
|
||||
if (attachment.verifications.some((verification) => verification.status === "matched")) return false;
|
||||
const latest = attachment.verifications[0];
|
||||
if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) return false;
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted));
|
||||
const pages = recognition.pages ?? [];
|
||||
if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) ||
|
||||
recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) return false;
|
||||
const candidates = batchCandidates(config, batch);
|
||||
const digits = (value) => String(value ?? "").replace(/\D/g, "");
|
||||
for (const page of pages) {
|
||||
if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) return false;
|
||||
// A failed page counts only under the same strict rule as failure confirmation,
|
||||
// applied page by page: a failed page proves no payment on that page, so a
|
||||
// multi-page voided slip passes only if every page is a clear failure or a
|
||||
// readable, clearly different payment.
|
||||
if (page.transactionStatus === "failed") {
|
||||
if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue;
|
||||
return false;
|
||||
}
|
||||
// Any other page must be fully readable (the recognizer reports no issue).
|
||||
if ((page.issues ?? []).length) return false;
|
||||
if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) return false;
|
||||
// "Clearly another payment": a definite mismatch AND at least one account
|
||||
// differs from every candidate. Same accounts with a different amount may
|
||||
// be a misread of this very payment, so it keeps blocking.
|
||||
if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") return false;
|
||||
if (candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) &&
|
||||
digits(candidate.payeeAccount) === digits(page.payeeAccount))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertVoidedReceiptsResolved(tx, config, batch) {
|
||||
const voided = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: { not: null } },
|
||||
include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } });
|
||||
const unresolved = () => buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409);
|
||||
for (const attachment of voided) {
|
||||
if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) throw unresolved();
|
||||
if (attachment.verifications.some((verification) => verification.status === "matched")) throw unresolved();
|
||||
const latest = attachment.verifications[0];
|
||||
if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) throw unresolved();
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted));
|
||||
const pages = recognition.pages ?? [];
|
||||
if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) ||
|
||||
recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) throw unresolved();
|
||||
const { isClearBankFailure } = loanPayments();
|
||||
const candidates = batchCandidates(config, batch);
|
||||
const digits = (value) => String(value ?? "").replace(/\D/g, "");
|
||||
for (const page of pages) {
|
||||
if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) throw unresolved();
|
||||
// A failed page counts only under the same strict rule as failure confirmation,
|
||||
// applied page by page: a failed page proves no payment on that page, so a
|
||||
// multi-page voided slip passes only if every page is a clear failure or a
|
||||
// readable, clearly different payment.
|
||||
if (page.transactionStatus === "failed") {
|
||||
if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue;
|
||||
throw unresolved();
|
||||
}
|
||||
// Any other page must be fully readable (the recognizer reports no issue).
|
||||
if ((page.issues ?? []).length) throw unresolved();
|
||||
if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) throw unresolved();
|
||||
// "Clearly another payment": a definite mismatch AND at least one account
|
||||
// differs from every candidate. Same accounts with a different amount may
|
||||
// be a misread of this very payment, so it keeps blocking.
|
||||
if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") throw unresolved();
|
||||
const sameAccounts = candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) &&
|
||||
digits(candidate.payeeAccount) === digits(page.payeeAccount));
|
||||
if (sameAccounts) throw unresolved();
|
||||
}
|
||||
if (!voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,4 +244,5 @@ async function bankSubmissionState(tx, batch) {
|
||||
module.exports = {
|
||||
recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
|
||||
reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts,
|
||||
attestationMatches, voidedAttachmentResolved,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
|
||||
// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3).
|
||||
// A repayment is released as "not paid" only when every reviewed bank submission
|
||||
// has an independently reviewed terminal fact bound to its instruction reference,
|
||||
// every export is linked to a submission, no payment receipt is left unexplained
|
||||
// and the internal wait period has passed. The same check runs again in the
|
||||
// final cancel transaction, so a stale conclusion can never release principal.
|
||||
|
||||
const TERMINAL_KINDS = ["termination_evidence", "failure_receipt"];
|
||||
const TERMINATION_STATUSES = ["rejected", "revoked", "failed"];
|
||||
const PENDING_STATUSES = ["已受理", "受理成功", "处理中", "待处理"];
|
||||
const DEFAULT_RELEASE_WAIT_DAYS = 3;
|
||||
|
||||
const loanPayments = () => require("./companyLoanPayments");
|
||||
const submissions = () => require("./loanBankSubmissions");
|
||||
|
||||
// Instruction references are compared after NFKC, whitespace removal and upper-casing.
|
||||
function normalizeInstructionRef(value) {
|
||||
return typeof value === "string" ? value.normalize("NFKC").replace(/\s+/g, "").toUpperCase() : "";
|
||||
}
|
||||
|
||||
async function latestRecognition(tx, config, attachment) {
|
||||
const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachment.id },
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }] });
|
||||
if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) return null;
|
||||
return { verification, recognition: JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)) };
|
||||
}
|
||||
|
||||
// A pending slip of this batch's payment: a single complete page whose status is
|
||||
// a pending wording (its only issue being transaction_pending), with every field
|
||||
// read, matching the frozen payer/payee/amount/currency. Returns its reference.
|
||||
async function pendingSlipReference(tx, config, batch, attachment) {
|
||||
const { batchCandidates } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const latest = await latestRecognition(tx, config, attachment);
|
||||
const recognition = latest?.recognition;
|
||||
if (!recognition || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages?.length !== 1) return null;
|
||||
const page = recognition.pages[0];
|
||||
if (page.transactionStatus !== "pending" || !PENDING_STATUSES.includes(page.transactionStatusRaw) ||
|
||||
(page.issues ?? []).length !== 1 || page.issues[0] !== "transaction_pending" || !page.reference) return null;
|
||||
const match = matchBankSlip({ ...page, transactionStatus: "success", issues: [] }, batchCandidates(config, batch));
|
||||
return match.status === "matched" ? normalizeInstructionRef(page.reference) : null;
|
||||
}
|
||||
|
||||
// A strictly clear failed slip of this batch's payment; returns verification and reference.
|
||||
async function failureSlip(tx, config, batch, attachmentId) {
|
||||
const fact = await loanPayments().failedReceiptFact(tx, config, batch, attachmentId);
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, fact.verification.normalized_encrypted));
|
||||
return { ...fact, reference: normalizeInstructionRef(recognition.pages[0].reference) };
|
||||
}
|
||||
|
||||
// Re-validates a terminal fact against the current batch state. Throws the
|
||||
// reason when it no longer holds (submission withdrawn, evidence voided, the
|
||||
// failed slip re-recognized or voided, a resolved pending slip changed).
|
||||
async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
if (!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);
|
||||
} else {
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, fact.receipt_attachment_id); }
|
||||
catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }
|
||||
if (slip.verification.id !== fact.verification_id || slip.reference !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
// Same checks as the single-submission failure path: the transaction is not
|
||||
// claimed as a payment, uses the current key, backs no other batch, and with
|
||||
// same-amount competitors needs a reviewed failure attribution for exactly
|
||||
// this submission.
|
||||
const { assertFailureFingerprintFree, competingLoanBatches, attributionMatches, readLoanRepayment } = loanPayments();
|
||||
await assertFailureFingerprintFree(tx, batch, slip);
|
||||
const competitors = await competingLoanBatches(tx, batch, await readLoanRepayment(tx, batch));
|
||||
if (competitors.length) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission",
|
||||
status: "approved", covered_submission_ids: { equals: [submission.id] } } });
|
||||
if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
||||
}
|
||||
}
|
||||
for (const attachmentId of fact.resolves_attachment_ids ?? []) {
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||
// Only a pending slip of the same instruction can be settled by this fact.
|
||||
if (!attachment || attachment.payment_batch_id !== batch.id ||
|
||||
await pendingSlipReference(tx, config, batch, attachment) !== submission.bank_instruction_ref) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTerminalBatch(batch, repayment) {
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) ||
|
||||
repayment.allocations.some((a) => a.reservation_state !== "reserved")) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
}
|
||||
|
||||
async function proposeTerminalFact(tx, config, batch, request, input) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
await supersedeStaleTerminalFacts(tx, config, batch, request);
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } });
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (await tx.loanBankTerminalFact.count({ where: { submission_id: submission.id, status: { in: ["proposed", "approved"] } } })) {
|
||||
throw buildBusinessValidationError("loan.terminal_fact_exists", {}, 409);
|
||||
}
|
||||
const data = { payment_batch_id: batch.id, submission_id: submission.id, kind: input.kind, note: input.note,
|
||||
resolves_attachment_ids: [...new Set(input.resolvesAttachmentIds ?? [])], proposed_by: request.employee.id };
|
||||
if (input.kind === "termination_evidence") {
|
||||
if (!TERMINATION_STATUSES.includes(input.terminalStatus) || !input.evidenceId) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
Object.assign(data, { terminal_status: input.terminalStatus, evidence_id: input.evidenceId });
|
||||
} else {
|
||||
if (!input.receiptAttachmentId) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, input.receiptAttachmentId); }
|
||||
catch { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); }
|
||||
Object.assign(data, { terminal_status: "failed", receipt_attachment_id: input.receiptAttachmentId, verification_id: slip.verification.id });
|
||||
}
|
||||
await assertTerminalFactHolds(tx, config, batch, data, submission);
|
||||
const fact = await tx.loanBankTerminalFact.create({ data });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_FACT_PROPOSED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, factId: fact.id, submissionId: submission.id, kind: input.kind });
|
||||
return fact;
|
||||
}
|
||||
|
||||
// Everyone who produced the bank-side record of this batch, plus the batch
|
||||
// creator, submitter and lender employee: none may review or confirm.
|
||||
async function terminalParticipants(tx, batch, repayment) {
|
||||
const { reconciliationParticipants } = loanPayments();
|
||||
const [facts, evidences] = await Promise.all([
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, select: { proposed_by: true } }),
|
||||
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
|
||||
]);
|
||||
const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } });
|
||||
return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)),
|
||||
...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean);
|
||||
}
|
||||
|
||||
async function reviewTerminalFact(tx, config, batch, request, factId, input) {
|
||||
const { readLoanRepayment, activeReviewer } = loanPayments();
|
||||
const fact = await tx.loanBankTerminalFact.findUnique({ where: { id: factId } });
|
||||
if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true });
|
||||
if (input.decision === "approve") {
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } });
|
||||
await assertTerminalFactHolds(tx, config, batch, fact, submission);
|
||||
if (fact.kind === "failure_receipt") {
|
||||
await loanPayments().recordFailureEvidenceUse(tx, batch, await failureSlip(tx, config, batch, fact.receipt_attachment_id), "terminal_fact");
|
||||
}
|
||||
}
|
||||
const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: {
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason } });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
// Approved facts that no longer hold are closed as superseded so that a fresh
|
||||
// fact can be registered for the same submission.
|
||||
async function supersedeStaleTerminalFacts(tx, config, batch, request) {
|
||||
// (Hoisted: also called after evidence is voided.)
|
||||
const facts = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } },
|
||||
include: { submission: true } });
|
||||
for (const fact of facts) {
|
||||
try { await assertTerminalFactHolds(tx, config, batch, fact, fact.submission); }
|
||||
catch {
|
||||
await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: { status: "superseded", superseded_at: new Date() } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_FACT_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseWaitDays(config) {
|
||||
const value = Number(config?.loanReleaseWaitDays);
|
||||
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_RELEASE_WAIT_DAYS;
|
||||
}
|
||||
|
||||
// The full "not paid" release condition, evaluated under the batch lock both
|
||||
// when confirming and again in the final cancel transaction.
|
||||
async function assertNotPaidReleasable(tx, config, batch) {
|
||||
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);
|
||||
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const covered = new Set();
|
||||
const usedFacts = [];
|
||||
for (const submission of facts.active) {
|
||||
const fact = terminal.find((item) => item.submission_id === submission.id);
|
||||
if (!fact) throw buildBusinessValidationError("loan.terminal_coverage_incomplete", {}, 409);
|
||||
await assertTerminalFactHolds(tx, config, batch, fact, submission);
|
||||
if (fact.receipt_attachment_id) covered.add(fact.receipt_attachment_id);
|
||||
for (const id of fact.resolves_attachment_ids ?? []) covered.add(id);
|
||||
usedFacts.push(fact.id);
|
||||
}
|
||||
// Every export must be accounted for by a submission that reached a terminal state.
|
||||
const linkedExports = new Set(facts.active.map((item) => item.export_id).filter(Boolean));
|
||||
if (facts.exports.some((item) => !linkedExports.has(item.id))) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
// No payment receipt may stay unexplained: covered by a terminal fact, or a
|
||||
// voided slip that is provably a failure or a clearly different payment.
|
||||
const receipts = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id },
|
||||
include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } });
|
||||
for (const attachment of receipts) {
|
||||
if (covered.has(attachment.id)) continue;
|
||||
if (!attachment.archived_at || !voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.receipt_unresolved_for_release", {}, 409);
|
||||
}
|
||||
await assertReleaseWaitElapsed(tx, config, batch);
|
||||
return usedFacts;
|
||||
}
|
||||
|
||||
// Internal wait after the last payment-order export, required on every release
|
||||
// path (not-paid and single-submission failure), at confirmation and at cancel.
|
||||
async function assertReleaseWaitElapsed(tx, config, batch) {
|
||||
const { submissionFacts } = submissions();
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const lastExport = [batch.payment_order_last_exported_at, facts.legacy?.last_exported_at, ...facts.exports.map((item) => item.exported_at)]
|
||||
.filter(Boolean).map((value) => new Date(value).getTime()).reduce((max, value) => Math.max(max, value), 0);
|
||||
if (lastExport && Date.now() - lastExport < releaseWaitDays(config) * 24 * 60 * 60 * 1000) throw buildBusinessValidationError("loan.release_wait_period", {}, 409);
|
||||
}
|
||||
|
||||
async function confirmNotPaid(tx, config, batch, request) {
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { readLoanRepayment, activeReviewer, validateLoanPayment } = loanPayments();
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
assertTerminalBatch(batch, repayment);
|
||||
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true });
|
||||
const usedFacts = await assertNotPaidReleasable(tx, config, batch);
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts });
|
||||
}
|
||||
|
||||
async function terminalState(tx, batch) {
|
||||
const [evidences, terminal] = await Promise.all([
|
||||
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ uploaded_at: "asc" }, { id: "asc" }] }),
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }] }),
|
||||
]);
|
||||
return {
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, 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 })),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
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,
|
||||
file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_UPLOADED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
return evidence;
|
||||
}
|
||||
|
||||
// Voiding evidence invalidates every fact that relied on it.
|
||||
async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) {
|
||||
// After release the evidence is part of the closed record; later facts belong
|
||||
// to the late-result reconciliation flow, not to rewriting this one.
|
||||
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
const voided = await tx.loanBankTerminalEvidence.update({ where: { id: evidence.id }, data: {
|
||||
voided_at: new Date(), voided_by: request.employee.id, void_reason: reason } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_VOIDED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
await supersedeStaleTerminalFacts(tx, config, batch, request);
|
||||
return voided;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadTerminalEvidence, voidTerminalEvidence,
|
||||
normalizeInstructionRef, proposeTerminalFact, reviewTerminalFact, supersedeStaleTerminalFacts, assertNotPaidReleasable,
|
||||
confirmNotPaid, terminalState, terminalParticipants, DEFAULT_RELEASE_WAIT_DAYS, assertReleaseWaitElapsed,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户