借款对账 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]>
这个提交包含在:
@@ -0,0 +1,244 @@
|
||||
-- S3 of the loan bank-result reconciliation design (design.md 3.2): a
|
||||
-- repayment is released as "not paid" only when every reviewed bank submission
|
||||
-- has an independently reviewed terminal fact bound to its instruction reference.
|
||||
|
||||
-- Bank termination evidence (bank letters, official instruction-query PDFs) is
|
||||
-- stored apart from payment receipts: never OCR'd, never counted as a receipt,
|
||||
-- never changes the payment state. It can only be voided once.
|
||||
CREATE TABLE "loan_bank_terminal_evidences" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"original_name" VARCHAR(255) NOT NULL,
|
||||
"mime_type" VARCHAR(100) NOT NULL,
|
||||
"file_size" INTEGER NOT NULL,
|
||||
"file_sha256" VARCHAR(64) NOT NULL,
|
||||
"uploaded_by" UUID NOT NULL,
|
||||
"uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"voided_by" UUID,
|
||||
"voided_at" TIMESTAMP(3),
|
||||
"void_reason" VARCHAR(500),
|
||||
|
||||
CONSTRAINT "loan_bank_terminal_evidences_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_terminal_evidences_void_check" CHECK (
|
||||
("voided_at" IS NULL AND "voided_by" IS NULL AND "void_reason" IS NULL)
|
||||
OR ("voided_at" IS NOT NULL AND "voided_by" IS NOT NULL AND char_length(btrim("void_reason")) >= 2))
|
||||
);
|
||||
CREATE INDEX "loan_bank_terminal_evidences_batch_idx" ON "loan_bank_terminal_evidences"("payment_batch_id");
|
||||
CREATE INDEX "loan_bank_terminal_evidences_sha_idx" ON "loan_bank_terminal_evidences"("file_sha256");
|
||||
|
||||
-- One terminal fact per bank submission (proposed/approved at a time).
|
||||
-- kind failure_receipt: a strictly clear failed slip whose reference equals the
|
||||
-- submission's instruction reference. kind termination_evidence: bank evidence
|
||||
-- naming that instruction and its terminal status. resolves_attachment_ids lists
|
||||
-- pending slips of the same instruction that this terminal fact settles.
|
||||
CREATE TABLE "loan_bank_terminal_facts" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"submission_id" UUID NOT NULL,
|
||||
"kind" VARCHAR(32) NOT NULL,
|
||||
"terminal_status" VARCHAR(16) NOT NULL,
|
||||
"evidence_id" UUID,
|
||||
"receipt_attachment_id" UUID,
|
||||
"verification_id" UUID,
|
||||
"resolves_attachment_ids" JSONB NOT NULL DEFAULT '[]',
|
||||
"note" VARCHAR(1000) NOT NULL,
|
||||
"status" VARCHAR(16) NOT NULL DEFAULT 'proposed',
|
||||
"proposed_by" UUID NOT NULL,
|
||||
"proposed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewed_by" UUID,
|
||||
"reviewed_at" TIMESTAMP(3),
|
||||
"review_note" VARCHAR(500),
|
||||
"superseded_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "loan_bank_terminal_facts_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_terminal_facts_kind_check" CHECK (
|
||||
("kind" = 'termination_evidence' AND "evidence_id" IS NOT NULL AND "receipt_attachment_id" IS NULL AND "verification_id" IS NULL
|
||||
AND "terminal_status" IN ('rejected', 'revoked', 'failed'))
|
||||
OR ("kind" = 'failure_receipt' AND "evidence_id" IS NULL AND "receipt_attachment_id" IS NOT NULL AND "verification_id" IS NOT NULL
|
||||
AND "terminal_status" = 'failed')),
|
||||
CONSTRAINT "loan_bank_terminal_facts_resolves_check" CHECK (jsonb_typeof("resolves_attachment_ids") = 'array'),
|
||||
CONSTRAINT "loan_bank_terminal_facts_note_check" CHECK (char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_terminal_facts_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
|
||||
CONSTRAINT "loan_bank_terminal_facts_review_check" CHECK (
|
||||
("status" = 'proposed' AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
|
||||
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL)
|
||||
OR "status" = 'superseded'),
|
||||
CONSTRAINT "loan_bank_terminal_facts_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
|
||||
CONSTRAINT "loan_bank_terminal_facts_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
|
||||
);
|
||||
CREATE UNIQUE INDEX "loan_bank_terminal_facts_one_active_per_submission"
|
||||
ON "loan_bank_terminal_facts"("submission_id") WHERE "status" IN ('proposed', 'approved');
|
||||
CREATE INDEX "loan_bank_terminal_facts_batch_idx" ON "loan_bank_terminal_facts"("payment_batch_id", "status");
|
||||
|
||||
ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_evidences" ADD CONSTRAINT "loan_bank_terminal_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_submission_id_fkey" FOREIGN KEY ("submission_id") REFERENCES "loan_bank_submissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_evidence_id_fkey" FOREIGN KEY ("evidence_id") REFERENCES "loan_bank_terminal_evidences"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_receipt_attachment_id_fkey" FOREIGN KEY ("receipt_attachment_id") REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_verification_id_fkey" FOREIGN KEY ("verification_id") REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_terminal_facts" ADD CONSTRAINT "loan_bank_terminal_facts_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
CREATE FUNCTION guard_loan_terminal_evidence_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal evidence history is immutable'; END IF;
|
||||
IF OLD."voided_at" IS NOT NULL OR NEW."voided_at" IS NULL
|
||||
OR (NEW."payment_batch_id", NEW."storage_path", NEW."original_name", NEW."mime_type", NEW."file_size", NEW."file_sha256", NEW."uploaded_by", NEW."uploaded_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."storage_path", OLD."original_name", OLD."mime_type", OLD."file_size", OLD."file_sha256", OLD."uploaded_by", OLD."uploaded_at") THEN
|
||||
RAISE EXCEPTION 'loan terminal evidence can only be voided once';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_terminal_evidence_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_evidences"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_evidence_history();
|
||||
|
||||
CREATE FUNCTION guard_loan_terminal_fact_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan terminal fact history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan terminal fact is immutable'; END IF;
|
||||
IF (NEW."payment_batch_id", NEW."submission_id", NEW."kind", NEW."terminal_status", NEW."evidence_id", NEW."receipt_attachment_id",
|
||||
NEW."verification_id", NEW."resolves_attachment_ids", NEW."note", NEW."proposed_by", NEW."proposed_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_id", OLD."kind", OLD."terminal_status", OLD."evidence_id", OLD."receipt_attachment_id",
|
||||
OLD."verification_id", OLD."resolves_attachment_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN
|
||||
RAISE EXCEPTION 'loan terminal fact proposal is immutable';
|
||||
END IF;
|
||||
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded'
|
||||
OR (NEW."reviewed_by", NEW."reviewed_at", NEW."review_note") IS DISTINCT FROM (OLD."reviewed_by", OLD."reviewed_at", OLD."review_note")) THEN
|
||||
RAISE EXCEPTION 'approved loan terminal fact can only be superseded';
|
||||
END IF;
|
||||
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
|
||||
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'unreviewed loan terminal fact cannot carry review fields';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_terminal_fact_history BEFORE UPDATE OR DELETE ON "loan_bank_terminal_facts"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_terminal_fact_history();
|
||||
|
||||
-- New terminal repayment state confirmed_not_paid: like confirmed_failed it is
|
||||
-- terminal, needs a reference and allows the batch to be cancelled.
|
||||
ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check;
|
||||
ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK (
|
||||
amount > 0 AND currency_code = 'CNY' AND version > 0
|
||||
AND lender_identity_version > 0 AND lender_account_version > 0
|
||||
AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_success')
|
||||
AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL)
|
||||
AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL)
|
||||
OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL))
|
||||
);
|
||||
|
||||
-- Integrity and history functions re-created with confirmed_not_paid as a
|
||||
-- cancellable terminal state (otherwise identical to 20260917010000).
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_repayment_allocations' THEN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM
|
||||
(OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version)
|
||||
OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD)
|
||||
THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount,
|
||||
NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key)
|
||||
IS DISTINCT FROM
|
||||
(OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount,
|
||||
OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key)
|
||||
OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_success') AND
|
||||
(NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref))
|
||||
OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent')
|
||||
OR (OLD.accounting_handoff_at IS NOT NULL AND
|
||||
(NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM
|
||||
(OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref))
|
||||
THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- One failed bank transaction can back the release of only one batch, whichever
|
||||
-- path relied on it (single-submission confirmation or a per-submission terminal
|
||||
-- fact). Append-only registry keyed by the transaction fingerprint.
|
||||
CREATE TABLE "loan_failure_evidence_uses" (
|
||||
"fingerprint" VARCHAR(64) NOT NULL,
|
||||
"key_id" VARCHAR(64) NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"source" VARCHAR(32) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_failure_evidence_uses_pkey" PRIMARY KEY ("fingerprint"),
|
||||
CONSTRAINT "loan_failure_evidence_uses_source_check" CHECK ("source" IN ('failure_confirmation', 'terminal_fact'))
|
||||
);
|
||||
ALTER TABLE "loan_failure_evidence_uses" ADD CONSTRAINT "loan_failure_evidence_uses_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
CREATE TRIGGER loan_failure_evidence_uses_append_only BEFORE UPDATE OR DELETE ON "loan_failure_evidence_uses"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
|
||||
-- Failure attributions now state their scope: the single-submission batch path
|
||||
-- (S2) or one named submission bound by instruction reference (S3).
|
||||
ALTER TABLE "loan_receipt_attributions" ADD COLUMN "failure_scope" VARCHAR(16) NOT NULL DEFAULT 'batch';
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_failure_scope_check" CHECK ("failure_scope" IN ('batch', 'submission'));
|
||||
CREATE OR REPLACE FUNCTION guard_loan_receipt_attribution_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan receipt attribution history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan receipt attribution is immutable'; END IF;
|
||||
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' OR NEW."reviewed_by" IS DISTINCT FROM OLD."reviewed_by"
|
||||
OR NEW."reviewed_at" IS DISTINCT FROM OLD."reviewed_at" OR NEW."review_note" IS DISTINCT FROM OLD."review_note") THEN
|
||||
RAISE EXCEPTION 'approved loan receipt attribution can only be superseded';
|
||||
END IF;
|
||||
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
|
||||
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'unreviewed loan receipt attribution cannot carry review fields';
|
||||
END IF;
|
||||
IF NEW."payment_batch_id" <> OLD."payment_batch_id" OR NEW."fingerprint" <> OLD."fingerprint" OR NEW."key_id" <> OLD."key_id"
|
||||
OR NEW."verification_ids" <> OLD."verification_ids" OR NEW."manual_proposal_ids" <> OLD."manual_proposal_ids"
|
||||
OR NEW."competitor_batch_ids" <> OLD."competitor_batch_ids" OR NEW."basis_kind" <> OLD."basis_kind"
|
||||
OR NEW."basis_note" <> OLD."basis_note" OR NEW."exclusion_note" <> OLD."exclusion_note"
|
||||
OR NEW."proposed_by" <> OLD."proposed_by" OR NEW."proposed_at" <> OLD."proposed_at"
|
||||
OR NEW."kind" <> OLD."kind" OR NEW."covered_submission_ids" <> OLD."covered_submission_ids"
|
||||
OR NEW."failure_scope" <> OLD."failure_scope" THEN
|
||||
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- Per-submission failure attributions: at most one open/approved per batch,
|
||||
-- kind and covered submissions (settlement keeps covered = [] → one per batch).
|
||||
DROP INDEX "loan_receipt_attributions_one_active_per_batch_kind";
|
||||
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_scope"
|
||||
ON "loan_receipt_attributions"("payment_batch_id", "kind", "failure_scope", "covered_submission_ids") WHERE "status" IN ('proposed', 'approved');
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_submission_scope_check" CHECK (
|
||||
"failure_scope" <> 'submission' OR ("kind" = 'failure' AND jsonb_array_length("covered_submission_ids") = 1));
|
||||
@@ -741,6 +741,9 @@ model Employee {
|
||||
loan_bank_submissions_registered LoanBankSubmission[]
|
||||
loan_submission_attestations_proposed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationProposer")
|
||||
loan_submission_attestations_reviewed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationReviewer")
|
||||
loan_terminal_evidences_uploaded LoanBankTerminalEvidence[]
|
||||
loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer")
|
||||
loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer")
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
@@ -1985,6 +1988,9 @@ model PaymentBatch {
|
||||
loan_legacy_export_summary LoanLegacyExportSummary?
|
||||
loan_bank_submissions LoanBankSubmission[]
|
||||
loan_bank_submission_attestations LoanBankSubmissionAttestation[]
|
||||
loan_terminal_evidences LoanBankTerminalEvidence[]
|
||||
loan_terminal_facts LoanBankTerminalFact[]
|
||||
loan_failure_evidence_uses LoanFailureEvidenceUse[]
|
||||
@@map("payment_batches")
|
||||
}
|
||||
|
||||
@@ -2043,6 +2049,7 @@ model PaymentBatchReceiptAttachment {
|
||||
receipt_ocr_state String @default("legacy") @db.VarChar(32)
|
||||
receipt_ocr_started_at DateTime?
|
||||
|
||||
loan_terminal_facts LoanBankTerminalFact[]
|
||||
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
|
||||
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
|
||||
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
|
||||
@@ -2066,6 +2073,7 @@ model PaymentReceiptVerification {
|
||||
claims PaymentReceiptClaim[]
|
||||
manual_proposals PaymentReceiptManualProposal[]
|
||||
|
||||
loan_terminal_facts LoanBankTerminalFact[]
|
||||
@@index([attachment_id, created_at])
|
||||
@@map("payment_receipt_verifications")
|
||||
}
|
||||
@@ -4004,6 +4012,7 @@ model LoanReceiptAttribution {
|
||||
manual_proposal_ids Json
|
||||
kind String @default("settlement") @db.VarChar(16)
|
||||
covered_submission_ids Json @default("[]")
|
||||
failure_scope String @default("batch") @db.VarChar(16)
|
||||
competitor_batch_ids Json
|
||||
basis_kind String @db.VarChar(32)
|
||||
basis_note String @db.VarChar(1000)
|
||||
@@ -4076,6 +4085,8 @@ model LoanBankSubmission {
|
||||
export PaymentOrderExport? @relation(fields: [export_id], references: [id], onDelete: Restrict)
|
||||
registrar Employee @relation(fields: [registered_by], references: [id], onDelete: Restrict)
|
||||
|
||||
terminal_facts LoanBankTerminalFact[]
|
||||
|
||||
@@index([payment_batch_id, status], map: "loan_bank_submissions_batch_idx")
|
||||
@@map("loan_bank_submissions")
|
||||
}
|
||||
@@ -4101,3 +4112,71 @@ model LoanBankSubmissionAttestation {
|
||||
|
||||
@@map("loan_bank_submission_attestations")
|
||||
}
|
||||
|
||||
// Loan bank-result reconciliation S3 (migration 20261008010000_loan_bank_terminal_facts).
|
||||
// Evidence is stored apart from payment receipts; history triggers live in the migration.
|
||||
model LoanBankTerminalEvidence {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
storage_path String
|
||||
original_name String @db.VarChar(255)
|
||||
mime_type String @db.VarChar(100)
|
||||
file_size Int
|
||||
file_sha256 String @db.VarChar(64)
|
||||
uploaded_by String @db.Uuid
|
||||
uploaded_at DateTime @default(now())
|
||||
voided_by String? @db.Uuid
|
||||
voided_at DateTime?
|
||||
void_reason String? @db.VarChar(500)
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
facts LoanBankTerminalFact[]
|
||||
|
||||
@@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx")
|
||||
@@map("loan_bank_terminal_evidences")
|
||||
}
|
||||
|
||||
model LoanBankTerminalFact {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
submission_id String @db.Uuid
|
||||
kind String @db.VarChar(32)
|
||||
terminal_status String @db.VarChar(16)
|
||||
evidence_id String? @db.Uuid
|
||||
receipt_attachment_id String? @db.Uuid
|
||||
verification_id String? @db.Uuid
|
||||
resolves_attachment_ids Json @default("[]")
|
||||
note String @db.VarChar(1000)
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String? @db.VarChar(500)
|
||||
superseded_at DateTime?
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
submission LoanBankSubmission @relation(fields: [submission_id], references: [id], onDelete: Restrict)
|
||||
evidence LoanBankTerminalEvidence? @relation(fields: [evidence_id], references: [id], onDelete: Restrict)
|
||||
receipt_attachment PaymentBatchReceiptAttachment? @relation(fields: [receipt_attachment_id], references: [id], onDelete: Restrict)
|
||||
verification PaymentReceiptVerification? @relation(fields: [verification_id], references: [id], onDelete: Restrict)
|
||||
proposer Employee @relation("LoanTerminalFactProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
|
||||
reviewer Employee? @relation("LoanTerminalFactReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([payment_batch_id, status], map: "loan_bank_terminal_facts_batch_idx")
|
||||
@@map("loan_bank_terminal_facts")
|
||||
}
|
||||
|
||||
// One failed bank transaction backs the release of at most one batch.
|
||||
model LoanFailureEvidenceUse {
|
||||
fingerprint String @id @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
payment_batch_id String @db.Uuid
|
||||
source String @db.VarChar(32)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@map("loan_failure_evidence_uses")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ const ERROR_REASONS = [
|
||||
{"code":"loan.voided_receipt_unresolved","domain":"loan","audience":"user","defaultMessage":"已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_export_invalid","domain":"loan","audience":"user","defaultMessage":"所选导出记录不属于本批次,或网银导入的提交未关联导出记录。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_attestation_exists","domain":"loan","audience":"user","defaultMessage":"本批次已有待复核或已通过的提交清单确认,请勿重复提交。","recovery":"follow_guidance"},
|
||||
{"code":"loan.terminal_fact_invalid","domain":"loan","audience":"user","defaultMessage":"银行终态事实与提交、证据或回单不一致,或证据已作废、已变化,须重新登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.terminal_coverage_incomplete","domain":"loan","audience":"user","defaultMessage":"尚有银行提交没有经复核的终态事实,不能确认未付款或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.export_unlinked","domain":"loan","audience":"user","defaultMessage":"有导出记录没有关联到已登记的银行提交,须先核实该次导出是否提交银行。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_unresolved_for_release","domain":"loan","audience":"user","defaultMessage":"本批次有银行回单尚未被终态事实覆盖,可能显示付款成功或处理中,不能释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.release_wait_period","domain":"loan","audience":"user","defaultMessage":"距最后一次导出未满内部等待期,暂不能确认未付款或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.instruction_ref_conflict","domain":"loan","audience":"user","defaultMessage":"该银行指令号已登记在其他还款批次,请核对后重新登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.terminal_fact_exists","domain":"loan","audience":"user","defaultMessage":"该银行提交已有待复核或已通过的终态事实,请勿重复登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.terminal_evidence_invalid","domain":"loan","audience":"user","defaultMessage":"终态证据文件不符合要求,或已作废、不属于本批次。","recovery":"follow_guidance"},
|
||||
{"code":"loan.failure_evidence_reused","domain":"loan","audience":"user","defaultMessage":"该笔银行失败交易已作为其他还款批次的依据,不能重复使用。","recovery":"follow_guidance"},
|
||||
{"code":"config.enabled_query_invalid","domain":"config","audience":"user","defaultMessage":"启用状态筛选无效,请重新选择后查询。","recovery":"follow_guidance"},
|
||||
{"code":"config.document_type_invalid","domain":"config","audience":"user","defaultMessage":"单据类型标识不符合要求,请重新选择单据类型。","recovery":"follow_guidance"},
|
||||
{"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -6,6 +6,8 @@ const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRe
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
|
||||
const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
|
||||
reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions");
|
||||
const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid,
|
||||
terminalState } = require("../services/loanBankTerminalFacts");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { ERROR_REASONS } = require("../constants/errorReasons");
|
||||
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
|
||||
@@ -1051,7 +1053,7 @@ 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) };
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch) };
|
||||
})() } : {}),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
@@ -1497,7 +1499,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100),
|
||||
}).strict();
|
||||
const ATTRIBUTION_PROPOSAL_SCHEMA = z.object({
|
||||
kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: UUID_SCHEMA.optional(),
|
||||
kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: UUID_SCHEMA.optional(), submissionId: UUID_SCHEMA.optional(),
|
||||
basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved", "bank_instruction_ref"]),
|
||||
basisNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
@@ -1550,6 +1552,90 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: 10 * 1024 * 1024 } })) {
|
||||
if (part.type !== "file" || part.fieldname !== "file") throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400);
|
||||
const buffer = await part.toBuffer();
|
||||
files.push(await validateAndNormalizePurchaseAttachment({ buffer, fileName: part.filename, declaredMimeType: part.mimetype,
|
||||
normalizeImages: true, maxBytes: 10 * 1024 * 1024 }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PurchaseAttachmentPolicyError || String(error.code ?? "").startsWith("FST_")) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400);
|
||||
throw error;
|
||||
}
|
||||
if (files.length !== 1) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 400);
|
||||
const [file] = files;
|
||||
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 }));
|
||||
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"); }
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
// Reviewers must see the evidence they approve: finance or an eligible
|
||||
// independent reviewer (preHandler), with a recorded reason and digest check.
|
||||
fastify.get("/payment-batches/:id/loan-terminal-evidences/:evidenceId/file", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const batchId = parseId(request.params.id);
|
||||
const evidenceId = parseId(request.params.evidenceId);
|
||||
// The reason travels in a header (URL-encoded) so it stays out of access logs.
|
||||
let headerReason = "";
|
||||
try { headerReason = decodeURIComponent(String(request.headers["x-access-reason"] ?? "")); } catch { headerReason = ""; }
|
||||
const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), { reason: headerReason });
|
||||
const evidence = await fastify.prisma.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } });
|
||||
if (!evidence || evidence.payment_batch_id !== batchId) throw buildNotFoundError();
|
||||
let buffer;
|
||||
try {
|
||||
const opened = await openReadStream({ config: fastify.config, storagePath: evidence.storage_path });
|
||||
const chunks = []; for await (const chunk of opened.stream) chunks.push(chunk); buffer = Buffer.concat(chunks);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) throw buildNotFoundError();
|
||||
throw error;
|
||||
}
|
||||
if (crypto.createHash("sha256").update(buffer).digest("hex") !== evidence.file_sha256) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LOAN_TERMINAL_EVIDENCE_VIEWED",
|
||||
targetType: "payment_batch", targetId: batchId, extra: { evidenceId, reason, voided: Boolean(evidence.voided_at) } }) });
|
||||
reply.header("content-type", evidence.mime_type);
|
||||
reply.header("content-disposition", buildAttachmentContentDisposition(evidence.original_name));
|
||||
reply.header("cache-control", "no-store");
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
return reply.send(buffer);
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-evidences/:evidenceId/void", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const evidenceId = parseId(request.params.evidenceId);
|
||||
const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => voidTerminalEvidence(tx, fastify.config, batch, request, evidenceId, reason));
|
||||
return sendData(reply, request, { id: result.id });
|
||||
});
|
||||
const TERMINAL_FACT_SCHEMA = z.object({
|
||||
submissionId: UUID_SCHEMA, kind: z.enum(["termination_evidence", "failure_receipt"]),
|
||||
terminalStatus: z.enum(["rejected", "revoked", "failed"]).optional(), evidenceId: UUID_SCHEMA.optional(),
|
||||
receiptAttachmentId: UUID_SCHEMA.optional(), resolvesAttachmentIds: z.array(UUID_SCHEMA).max(20).optional(),
|
||||
note: z.string().trim().min(5).max(1000),
|
||||
}).strict();
|
||||
fastify.post("/payment-batches/:id/loan-terminal-facts", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const body = parseBody(TERMINAL_FACT_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => proposeTerminalFact(tx, fastify.config, batch, request, body));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-facts/:factId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const factId = parseId(request.params.factId);
|
||||
const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => reviewTerminalFact(tx, fastify.config, batch, request, factId, body));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
// Reviewer action ("/review" suffix routes through the reviewer preHandler branch).
|
||||
fastify.post("/payment-batches/:id/loan-not-paid/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
parseBody(z.object({}).strict(), request.body ?? {});
|
||||
await attributionTransaction(request, (tx, batch) => confirmNotPaid(tx, fastify.config, batch, request));
|
||||
return sendData(reply, request, { status: "confirmed_not_paid" });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const id = parseId(request.params.id);
|
||||
const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body);
|
||||
@@ -1870,8 +1956,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (batch.entity_type === "loan_repayment") {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
|
||||
if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
// 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);
|
||||
downloadContext.purpose = "loan_principal_repayment";
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
@@ -2096,8 +2184,11 @@ async function paymentBatchesRoutes(fastify) {
|
||||
// Becoming a same-amount competitor must serialize with settlement's
|
||||
// competitor check, which runs under the same lender lock (batch -> lender).
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
// 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)) {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
}
|
||||
const created = [];
|
||||
for (const file of storedFiles) {
|
||||
@@ -2875,7 +2966,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (["executed", "cancelled"].includes(batch.status)) {
|
||||
throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409);
|
||||
}
|
||||
if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason);
|
||||
if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config);
|
||||
const openItemIds = batch.items
|
||||
.filter((item) => ["pending", "failed"].includes(item.status))
|
||||
.map((item) => item.id);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -27,6 +27,15 @@
|
||||
"loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。",
|
||||
"loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。",
|
||||
"loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。",
|
||||
"loan.terminal_fact_invalid": "银行终态事实与提交、证据或回单不一致,或证据已作废、已变化,须重新登记。",
|
||||
"loan.terminal_coverage_incomplete": "尚有银行提交没有经复核的终态事实,不能确认未付款或释放额度。",
|
||||
"loan.export_unlinked": "有导出记录没有关联到已登记的银行提交,须先核实该次导出是否提交银行。",
|
||||
"loan.receipt_unresolved_for_release": "本批次有银行回单尚未被终态事实覆盖,可能显示付款成功或处理中,不能释放额度。",
|
||||
"loan.release_wait_period": "距最后一次导出未满内部等待期,暂不能确认未付款或释放额度。",
|
||||
"loan.instruction_ref_conflict": "该银行指令号已登记在其他还款批次,请核对后重新登记。",
|
||||
"loan.terminal_fact_exists": "该银行提交已有待复核或已通过的终态事实,请勿重复登记。",
|
||||
"loan.terminal_evidence_invalid": "终态证据文件不符合要求,或已作废、不属于本批次。",
|
||||
"loan.failure_evidence_reused": "该笔银行失败交易已作为其他还款批次的依据,不能重复使用。",
|
||||
"loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。",
|
||||
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
|
||||
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",
|
||||
|
||||
在新工单中引用
屏蔽一个用户