借款对账 S1、S2:导出与银行提交分开登记、确认失败收紧及失败回单识别口径(09-17-company-loan-principal-repayment)
S1(迁移 20261007010000) - 借款导出逐次记录;存量批次保留已知导出次数为历史汇总,不视为真实导出,存在即拦截 - 银行提交登记(只能撤回一次)与提交清单确认;独立复核,复核后任何登记、撤回、导出即失效 - 确认失败只计未作废附件;作废附件无法证明为明确失败或确属他笔付款即阻断 - S3 前临时拦截:多次导出、多次提交、清单未复核或历史汇总均不能确认失败 - 确认失败改按付款后规则;确认人不得兼任导出、提交登记、清单确认 S2(迁移 20261007020000) - 存在同金额候选时须有已复核、覆盖提交清单的失败归属;结算与失败归属分槽 失败回单识别口径(用户同意并入) - 新增原始状态措辞字段(多字段原文不一致即为空);失败事实严格判定:单页完整、 仅“交易失败/转账失败/付款失败/支付失败”、唯一问题为交易失败、字段齐全; 已撤销、已冲正、已退汇不接受;顶层识别状态与成功匹配不变 - 测试改走真实识别与归一化链,仅替换云端 OCR 响应 验证:借款 HTTP 41 组、浏览器 7 组、76 迁移;server 4463;共享回单测试 237; 三轮同模型只读复核的阻断项均已修复。 注意:界面未完成,不能单独部署;真实回单待验。未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,162 @@
|
||||
-- S1 of the loan bank-result reconciliation design (design.md 3.1):
|
||||
-- payment-order exports and bank submissions are different facts. One export
|
||||
-- file may be submitted zero, one or several times; the reviewed submission
|
||||
-- list, not the export count, is what failure/termination evidence must cover.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_order_exports" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"exported_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"exported_by" UUID NOT NULL,
|
||||
"file_sha256" VARCHAR(64) NOT NULL,
|
||||
"customer_serials" JSONB NOT NULL,
|
||||
"total_amount" DECIMAL(14,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "payment_order_exports_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "payment_order_exports_sequence_check" CHECK ("sequence" > 0),
|
||||
CONSTRAINT "payment_order_exports_sha_check" CHECK ("file_sha256" ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT "payment_order_exports_serials_check" CHECK (
|
||||
jsonb_typeof("customer_serials") = 'array' AND jsonb_array_length("customer_serials") > 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX "payment_order_exports_batch_sequence_key" ON "payment_order_exports"("payment_batch_id", "sequence");
|
||||
|
||||
-- Pre-existing loan batches only know how many times they were exported. The
|
||||
-- summary keeps that known count and states what is unknown; it is never a
|
||||
-- real export or bank submission and blocks failure confirmation and release.
|
||||
CREATE TABLE "loan_legacy_export_summaries" (
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"known_export_count" INTEGER NOT NULL,
|
||||
"last_exported_at" TIMESTAMP(3),
|
||||
"last_exported_by" UUID,
|
||||
"unknown_facts" VARCHAR(200) NOT NULL DEFAULT 'per_export_detail,file_digest,bank_submission_mapping',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_legacy_export_summaries_pkey" PRIMARY KEY ("payment_batch_id"),
|
||||
CONSTRAINT "loan_legacy_export_summaries_count_check" CHECK ("known_export_count" > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE "loan_bank_submissions" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"export_id" UUID,
|
||||
"channel" VARCHAR(32) NOT NULL,
|
||||
"submitted_at" TIMESTAMP(3) NOT NULL,
|
||||
"bank_instruction_ref" VARCHAR(120) NOT NULL,
|
||||
"customer_serial" VARCHAR(64),
|
||||
"note" VARCHAR(1000) NOT NULL,
|
||||
"status" VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
"withdrawn_reason" VARCHAR(500),
|
||||
"withdrawn_at" TIMESTAMP(3),
|
||||
"registered_by" UUID NOT NULL,
|
||||
"registered_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "loan_bank_submissions_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_submissions_channel_check" CHECK ("channel" IN ('online_banking_import', 'manual_entry', 'other')),
|
||||
CONSTRAINT "loan_bank_submissions_import_export_check" CHECK ("channel" <> 'online_banking_import' OR "export_id" IS NOT NULL),
|
||||
CONSTRAINT "loan_bank_submissions_ref_check" CHECK (char_length(btrim("bank_instruction_ref")) >= 2 AND char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_submissions_status_check" CHECK (
|
||||
("status" = 'active' AND "withdrawn_at" IS NULL AND "withdrawn_reason" IS NULL)
|
||||
OR ("status" = 'withdrawn' AND "withdrawn_at" IS NOT NULL AND char_length(btrim("withdrawn_reason")) >= 2))
|
||||
);
|
||||
CREATE INDEX "loan_bank_submissions_batch_idx" ON "loan_bank_submissions"("payment_batch_id", "status");
|
||||
|
||||
-- An independently reviewed statement that the active submissions (and the
|
||||
-- export records) listed are the complete set for the batch. It also snapshots
|
||||
-- every submission row including withdrawn ones, so any registration or
|
||||
-- withdrawal after review invalidates the approval.
|
||||
CREATE TABLE "loan_bank_submission_attestations" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"submission_ids" JSONB NOT NULL,
|
||||
"all_submission_ids" JSONB NOT NULL,
|
||||
"export_ids" JSONB NOT NULL,
|
||||
"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_submission_attestations_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "loan_bank_submission_attestations_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
|
||||
CONSTRAINT "loan_bank_submission_attestations_ids_check" CHECK (
|
||||
jsonb_typeof("submission_ids") = 'array' AND jsonb_array_length("submission_ids") > 0
|
||||
AND jsonb_typeof("all_submission_ids") = 'array' AND jsonb_typeof("export_ids") = 'array'),
|
||||
CONSTRAINT "loan_bank_submission_attestations_note_check" CHECK (char_length(btrim("note")) >= 5),
|
||||
CONSTRAINT "loan_bank_submission_attestations_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_submission_attestations_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
|
||||
CONSTRAINT "loan_bank_submission_attestations_independent_check" CHECK ("reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
|
||||
);
|
||||
CREATE UNIQUE INDEX "loan_bank_submission_attestations_one_active_per_batch"
|
||||
ON "loan_bank_submission_attestations"("payment_batch_id") WHERE "status" IN ('proposed', 'approved');
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "payment_order_exports" ADD CONSTRAINT "payment_order_exports_exported_by_fkey" FOREIGN KEY ("exported_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_legacy_export_summaries" ADD CONSTRAINT "loan_legacy_export_summaries_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_export_id_fkey" FOREIGN KEY ("export_id") REFERENCES "payment_order_exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submissions" ADD CONSTRAINT "loan_bank_submissions_registered_by_fkey" FOREIGN KEY ("registered_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "loan_bank_submission_attestations" ADD CONSTRAINT "loan_bank_submission_attestations_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: known export counts of existing loan batches become summaries.
|
||||
INSERT INTO "loan_legacy_export_summaries" ("payment_batch_id", "known_export_count", "last_exported_at", "last_exported_by")
|
||||
SELECT "id", "payment_order_export_count", "payment_order_last_exported_at", "payment_order_last_exported_by"
|
||||
FROM "payment_batches" WHERE "entity_type" = 'loan_repayment' AND "payment_order_export_count" > 0;
|
||||
|
||||
-- History guards: exports and summaries are append-only; submissions may only
|
||||
-- be withdrawn once; attestations follow proposed -> approved/rejected/superseded,
|
||||
-- approved -> superseded.
|
||||
CREATE FUNCTION guard_loan_reconciliation_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME;
|
||||
END $$;
|
||||
CREATE TRIGGER payment_order_exports_append_only BEFORE UPDATE OR DELETE ON "payment_order_exports"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE TRIGGER loan_legacy_export_summaries_append_only BEFORE UPDATE OR DELETE ON "loan_legacy_export_summaries"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
|
||||
CREATE FUNCTION guard_loan_bank_submission_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan bank submission history is immutable'; END IF;
|
||||
IF OLD."status" <> 'active' OR NEW."status" <> 'withdrawn'
|
||||
OR (NEW."payment_batch_id", NEW."export_id", NEW."channel", NEW."submitted_at", NEW."bank_instruction_ref",
|
||||
NEW."customer_serial", NEW."note", NEW."registered_by", NEW."registered_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."export_id", OLD."channel", OLD."submitted_at", OLD."bank_instruction_ref",
|
||||
OLD."customer_serial", OLD."note", OLD."registered_by", OLD."registered_at") THEN
|
||||
RAISE EXCEPTION 'loan bank submission can only be withdrawn once';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_submission_history BEFORE UPDATE OR DELETE ON "loan_bank_submissions"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_submission_history();
|
||||
|
||||
CREATE FUNCTION guard_loan_submission_attestation_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan submission attestation history is immutable'; END IF;
|
||||
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan submission attestation is immutable'; END IF;
|
||||
IF (NEW."payment_batch_id", NEW."submission_ids", NEW."all_submission_ids", NEW."export_ids", NEW."note", NEW."proposed_by", NEW."proposed_at")
|
||||
IS DISTINCT FROM (OLD."payment_batch_id", OLD."submission_ids", OLD."all_submission_ids", OLD."export_ids", OLD."note", OLD."proposed_by", OLD."proposed_at") THEN
|
||||
RAISE EXCEPTION 'loan submission attestation 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 submission attestation 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 submission attestation cannot carry review fields';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_submission_attestation_history BEFORE UPDATE OR DELETE ON "loan_bank_submission_attestations"
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_loan_submission_attestation_history();
|
||||
@@ -0,0 +1,45 @@
|
||||
-- S2 of the loan bank-result reconciliation design (design.md 3.1): a failed
|
||||
-- receipt on a batch with same-lender, same-amount competitors needs an
|
||||
-- independently reviewed attribution that names the bank submissions it covers.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "loan_receipt_attributions"
|
||||
ADD COLUMN "kind" VARCHAR(16) NOT NULL DEFAULT 'settlement',
|
||||
ADD COLUMN "covered_submission_ids" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_kind_check" CHECK ("kind" IN ('settlement', 'failure'));
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_covered_check" CHECK (
|
||||
jsonb_typeof("covered_submission_ids") = 'array'
|
||||
AND ("kind" <> 'failure' OR (jsonb_array_length("covered_submission_ids") > 0 AND jsonb_array_length("verification_ids") = 1)));
|
||||
|
||||
ALTER TABLE "loan_receipt_attributions" DROP CONSTRAINT "loan_receipt_attributions_basis_kind_check";
|
||||
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN
|
||||
('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved', 'bank_instruction_ref'));
|
||||
|
||||
-- One open/approved attribution per batch and kind.
|
||||
DROP INDEX "loan_receipt_attributions_one_active_per_batch";
|
||||
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch_kind"
|
||||
ON "loan_receipt_attributions"("payment_batch_id", "kind") WHERE "status" IN ('proposed', 'approved');
|
||||
|
||||
-- The proposal (now including kind and covered submissions) stays immutable.
|
||||
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" THEN
|
||||
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
@@ -737,6 +737,10 @@ model Employee {
|
||||
|
||||
loan_receipt_attributions_proposed LoanReceiptAttribution[] @relation("LoanReceiptAttributionProposer")
|
||||
loan_receipt_attributions_reviewed LoanReceiptAttribution[] @relation("LoanReceiptAttributionReviewer")
|
||||
payment_order_exports_made PaymentOrderExport[]
|
||||
loan_bank_submissions_registered LoanBankSubmission[]
|
||||
loan_submission_attestations_proposed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationProposer")
|
||||
loan_submission_attestations_reviewed LoanBankSubmissionAttestation[] @relation("LoanSubmissionAttestationReviewer")
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
@@ -1977,6 +1981,10 @@ model PaymentBatch {
|
||||
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
|
||||
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
|
||||
loan_receipt_attributions LoanReceiptAttribution[]
|
||||
payment_order_exports PaymentOrderExport[]
|
||||
loan_legacy_export_summary LoanLegacyExportSummary?
|
||||
loan_bank_submissions LoanBankSubmission[]
|
||||
loan_bank_submission_attestations LoanBankSubmissionAttestation[]
|
||||
@@map("payment_batches")
|
||||
}
|
||||
|
||||
@@ -3994,6 +4002,8 @@ model LoanReceiptAttribution {
|
||||
key_id String @db.VarChar(64)
|
||||
verification_ids Json
|
||||
manual_proposal_ids Json
|
||||
kind String @default("settlement") @db.VarChar(16)
|
||||
covered_submission_ids Json @default("[]")
|
||||
competitor_batch_ids Json
|
||||
basis_kind String @db.VarChar(32)
|
||||
basis_note String @db.VarChar(1000)
|
||||
@@ -4013,3 +4023,81 @@ model LoanReceiptAttribution {
|
||||
@@index([payment_batch_id, fingerprint], map: "loan_receipt_attributions_batch_fingerprint_idx")
|
||||
@@map("loan_receipt_attributions")
|
||||
}
|
||||
|
||||
// Loan bank-result reconciliation S1 (migration 20261007010000_loan_bank_submissions).
|
||||
// Append-only/one-way history triggers and partial unique index live in the migration.
|
||||
model PaymentOrderExport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
sequence Int
|
||||
exported_at DateTime @default(now())
|
||||
exported_by String @db.Uuid
|
||||
file_sha256 String @db.VarChar(64)
|
||||
customer_serials Json
|
||||
total_amount Decimal @db.Decimal(14, 2)
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
exporter Employee @relation(fields: [exported_by], references: [id], onDelete: Restrict)
|
||||
submissions LoanBankSubmission[]
|
||||
|
||||
@@unique([payment_batch_id, sequence], map: "payment_order_exports_batch_sequence_key")
|
||||
@@map("payment_order_exports")
|
||||
}
|
||||
|
||||
model LoanLegacyExportSummary {
|
||||
payment_batch_id String @id @db.Uuid
|
||||
known_export_count Int
|
||||
last_exported_at DateTime?
|
||||
last_exported_by String? @db.Uuid
|
||||
unknown_facts String @default("per_export_detail,file_digest,bank_submission_mapping") @db.VarChar(200)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@map("loan_legacy_export_summaries")
|
||||
}
|
||||
|
||||
model LoanBankSubmission {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
export_id String? @db.Uuid
|
||||
channel String @db.VarChar(32)
|
||||
submitted_at DateTime
|
||||
bank_instruction_ref String @db.VarChar(120)
|
||||
customer_serial String? @db.VarChar(64)
|
||||
note String @db.VarChar(1000)
|
||||
status String @default("active") @db.VarChar(16)
|
||||
withdrawn_reason String? @db.VarChar(500)
|
||||
withdrawn_at DateTime?
|
||||
registered_by String @db.Uuid
|
||||
registered_at DateTime @default(now())
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
export PaymentOrderExport? @relation(fields: [export_id], references: [id], onDelete: Restrict)
|
||||
registrar Employee @relation(fields: [registered_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([payment_batch_id, status], map: "loan_bank_submissions_batch_idx")
|
||||
@@map("loan_bank_submissions")
|
||||
}
|
||||
|
||||
model LoanBankSubmissionAttestation {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
submission_ids Json
|
||||
all_submission_ids Json
|
||||
export_ids Json
|
||||
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)
|
||||
proposer Employee @relation("LoanSubmissionAttestationProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
|
||||
reviewer Employee? @relation("LoanSubmissionAttestationReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@map("loan_bank_submission_attestations")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ const ERROR_REASONS = [
|
||||
{"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_exists","domain":"loan","audience":"user","defaultMessage":"本还款已有待复核或已通过的回单归属确认,请勿重复提交。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_required","domain":"loan","audience":"user","defaultMessage":"须先登记本批次实际向银行提交的指令,才能确认提交清单。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_history_unverified","domain":"loan","audience":"user","defaultMessage":"该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.multiple_submissions_unresolved","domain":"loan","audience":"user","defaultMessage":"该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。","recovery":"follow_guidance"},
|
||||
{"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":"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"},
|
||||
|
||||
@@ -4,6 +4,8 @@ const { loadPendingPaymentResponsibility } = require("../services/paymentRespons
|
||||
const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
||||
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure,
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
|
||||
const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
|
||||
reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { ERROR_REASONS } = require("../constants/errorReasons");
|
||||
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
|
||||
@@ -1048,7 +1050,8 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
return {
|
||||
...(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) };
|
||||
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
bankSubmissions: await bankSubmissionState(tx, batch) };
|
||||
})() } : {}),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
@@ -1494,7 +1497,8 @@ 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({
|
||||
basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"]),
|
||||
kind: z.enum(["settlement", "failure"]).default("settlement"), attachmentId: 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();
|
||||
const ATTRIBUTION_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(2).max(500) }).strict();
|
||||
@@ -1519,6 +1523,33 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await attributionTransaction(request, (tx, batch) => reviewReceiptAttribution(tx, fastify.config, batch, request, attributionId, body));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
const SUBMISSION_SCHEMA = z.object({
|
||||
exportId: UUID_SCHEMA.optional(), channel: z.enum(["online_banking_import", "manual_entry", "other"]),
|
||||
submittedAt: z.string().datetime({ offset: true }), bankInstructionRef: z.string().trim().min(2).max(120),
|
||||
customerSerial: z.string().trim().min(1).max(64).optional(), note: z.string().trim().min(5).max(1000),
|
||||
}).strict();
|
||||
fastify.post("/payment-batches/:id/loan-bank-submissions", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const body = parseBody(SUBMISSION_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => registerBankSubmission(tx, batch, request, body));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-submissions/:submissionId/withdraw", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const submissionId = parseId(request.params.submissionId);
|
||||
const { reason } = parseBody(z.object({ reason: z.string().trim().min(2).max(500) }).strict(), request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => withdrawBankSubmission(tx, batch, request, submissionId, reason));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-submission-attestation", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const { note } = parseBody(z.object({ note: z.string().trim().min(5).max(1000) }).strict(), request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => proposeSubmissionAttestation(tx, batch, request, note));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-submission-attestation/:attestationId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const attestationId = parseId(request.params.attestationId);
|
||||
const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
|
||||
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/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);
|
||||
@@ -1849,6 +1880,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw new PaymentOrderXlsError(blockers[0].code, { blockers });
|
||||
}
|
||||
const generated = generatePaymentOrderXls(fastify.config, batch);
|
||||
if (batch.entity_type === "loan_repayment") await recordLoanPaymentOrderExport(tx, batch, request, generated);
|
||||
const exportCount = Number(batch.payment_order_export_count ?? 0) + 1;
|
||||
const exportedAt = new Date();
|
||||
await tx.paymentBatch.update({
|
||||
|
||||
@@ -169,7 +169,7 @@ function repaymentMatchesInput(repayment, input) {
|
||||
repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount)));
|
||||
}
|
||||
|
||||
const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"];
|
||||
const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved", "bank_instruction_ref"];
|
||||
|
||||
// A matched receipt proves amount/accounts/currency/status, not which loan batch
|
||||
// the bank paid. Any other batch for the same lender and amount that ever reached
|
||||
@@ -192,10 +192,11 @@ const sameIdSet = (stored, current) => {
|
||||
// Valid only for the exact transaction, the exact receipt evidence that was
|
||||
// reviewed (same verifications and manual proposals; a re-scan or re-keyed
|
||||
// receipt of the same transaction invalidates it) and every current competitor.
|
||||
function attributionMatches(attribution, claim, competitors) {
|
||||
function attributionMatches(attribution, claim, competitors, coveredSubmissionIds = null) {
|
||||
return Boolean(claim) && attribution.fingerprint === claim.fingerprint && attribution.key_id === claim.keyId &&
|
||||
sameIdSet(attribution.verification_ids, claim.verificationIds) && sameIdSet(attribution.manual_proposal_ids, claim.manualProposalIds) &&
|
||||
attributionCovers(attribution, competitors);
|
||||
attributionCovers(attribution, competitors) &&
|
||||
(attribution.kind !== "failure" || (coveredSubmissionIds !== null && sameIdSet(attribution.covered_submission_ids, coveredSubmissionIds)));
|
||||
}
|
||||
|
||||
function attributionCovers(attribution, competitors) {
|
||||
@@ -215,7 +216,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
|
||||
// stores only one verification) so the approval's evidence binding is checked.
|
||||
const planned = claim && await plannedClaimOrNull(tx, config, batch);
|
||||
const approved = planned && planned.fingerprint === claim.fingerprint && await tx.loanReceiptAttribution.findFirst({ where: {
|
||||
payment_batch_id: batch.id, status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } });
|
||||
payment_batch_id: batch.id, kind: "settlement", status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } });
|
||||
if (!approved || !attributionMatches(approved, planned, competitors)) {
|
||||
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
||||
}
|
||||
@@ -255,43 +256,85 @@ async function loanReceiptParticipants(tx, batch) {
|
||||
submitterId: repayment.submitted_by }];
|
||||
}
|
||||
|
||||
async function confirmLoanBankFailure(tx, config, batch, request, attachmentId) {
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
// Exporters, submission registrars and submission-list attesters of the batch.
|
||||
async function reconciliationParticipants(tx, batch) {
|
||||
const { submissionFacts } = require("./loanBankSubmissions");
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const attestations = await tx.loanBankSubmissionAttestation.findMany({ where: { payment_batch_id: batch.id }, select: { proposed_by: true } });
|
||||
return [batch.payment_order_last_exported_by, facts.legacy?.last_exported_by, ...facts.exports.map((item) => item.exported_by),
|
||||
...facts.submissions.map((item) => item.registered_by), ...attestations.map((item) => item.proposed_by)].filter(Boolean);
|
||||
}
|
||||
|
||||
// Bank wordings accepted as "no money left the account". 已撤销 (may be before or
|
||||
// after execution) and 已冲正/已退汇 (paid then reversed/refunded) are excluded:
|
||||
// they belong to the termination and refund reconciliation flows.
|
||||
const CLEAR_FAILURE_STATUSES = ["交易失败", "转账失败", "付款失败", "支付失败"];
|
||||
|
||||
// "Clearly readable" is judged separately from "payment succeeded": the real
|
||||
// recognizer marks a failed slip needs_review because of its transaction_failed
|
||||
// issue. A failure fact is a single complete page whose only issue is that
|
||||
// failure, with every field read and an accepted failure wording.
|
||||
function isClearBankFailure(recognition) {
|
||||
if (!["recognized", "needs_review"].includes(recognition?.status) || recognition.pagesExpected !== 1 ||
|
||||
recognition.pagesProcessed !== 1 || recognition.pages?.length !== 1) return false;
|
||||
const page = recognition.pages[0];
|
||||
return page.pageNumber === 1 && page.transactionStatus === "failed" && CLEAR_FAILURE_STATUSES.includes(page.transactionStatusRaw) &&
|
||||
Array.isArray(page.issues) && page.issues.length === 1 && page.issues[0] === "transaction_failed" &&
|
||||
page.amount != null && Boolean(page.currency) && Boolean(page.payerAccount) && Boolean(page.payeeAccount) && Boolean(page.reference);
|
||||
}
|
||||
|
||||
// The verified failed receipt as a bank fact: a single recognized page that is
|
||||
// explicitly failed and matches this batch's frozen payer/payee/amount/currency.
|
||||
async function failedReceiptFact(tx, config, batch, attachmentId) {
|
||||
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
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`;
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id });
|
||||
// A failed receipt that may belong to a same-lender, same-amount batch cannot
|
||||
// release this reservation; without an attribution path for failures the
|
||||
// repayment stays reserved for bank reconciliation.
|
||||
if ((await competingLoanBatches(tx, batch, repayment)).length) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
||||
// More than one bank attempt/evidence is ambiguous: one failed attempt cannot
|
||||
// establish that another attempt did not pay. Preserve all reservations.
|
||||
if (await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (!attachment || attachment.payment_batch_id !== batch.id || attachment.archived_at ||
|
||||
attachment.governance_status !== "approved" || attachment.uploaded_by === request.employee.id) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
|
||||
if (!attachment || attachment.payment_batch_id !== batch.id || attachment.archived_at || attachment.governance_status !== "approved") { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
|
||||
const verification = await tx.paymentReceiptVerification.findFirst({ where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }] });
|
||||
if (!verification || verification.source_sha256 !== attachment.file_sha256 || verification.source_version !== attachment.receipt_ocr_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
|
||||
if (recognition.status !== "recognized" || recognition.pagesExpected !== 1 || recognition.pagesProcessed !== 1 || recognition.pages.length !== 1) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (!isClearBankFailure(recognition)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
const page = recognition.pages[0];
|
||||
if (page.transactionStatus !== "failed" || page.pageNumber !== 1 || !page.reference) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
// A confirmed bank failure still needs the exact payer/payee/amount/currency.
|
||||
// Reusing matching here never turns this evidence into a success verification.
|
||||
const match = matchBankSlip({ ...page, transactionStatus: "success", issues: (page.issues ?? []).filter((v) => v !== "transaction_failed") }, batchCandidates(config, batch));
|
||||
if (match.status !== "matched") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
const { fingerprint, key_id: keyId } = transactionFingerprint(config, page);
|
||||
if (await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (await tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint }, { item_id: batch.items[0].id }] } }) ||
|
||||
await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint } })) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
return { attachment, verification, fingerprint, keyId, verificationIds: [verification.id], manualProposalIds: [] };
|
||||
}
|
||||
|
||||
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);
|
||||
// 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 tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_failed", bank_result_ref: `verification:${verification.id}`,
|
||||
bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`,
|
||||
} });
|
||||
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id });
|
||||
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds });
|
||||
}
|
||||
|
||||
async function plannedLoanClaim(tx, config, batch) {
|
||||
@@ -318,16 +361,30 @@ async function assertAttributableBatch(tx, batch, { requireCompetitors = true }
|
||||
|
||||
// Finance documents why the verified bank transaction belongs to this batch and
|
||||
// why each competitor is excluded. Time alignment alone is not an accepted basis.
|
||||
// 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) {
|
||||
if (kind === "failure") {
|
||||
const { assertSingleAttestedSubmission } = 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 };
|
||||
}
|
||||
return { claim: await plannedLoanClaim(tx, config, batch), coveredSubmissionIds: null };
|
||||
}
|
||||
|
||||
async function proposeReceiptAttribution(tx, config, batch, request, input) {
|
||||
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
|
||||
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 = await plannedLoanClaim(tx, config, batch);
|
||||
const { claim, coveredSubmissionIds } = await attributionEvidence(tx, config, batch, kind, input.attachmentId);
|
||||
// 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, status: { in: ["proposed", "approved"] } } });
|
||||
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind, status: { in: ["proposed", "approved"] } } });
|
||||
if (active) {
|
||||
if (attributionMatches(active, claim, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); };
|
||||
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() } });
|
||||
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: active.id,
|
||||
reason: active.fingerprint !== claim.fingerprint || active.key_id !== claim.keyId ? "receipt_transaction_changed"
|
||||
@@ -336,11 +393,12 @@ 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 ?? [],
|
||||
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,
|
||||
} });
|
||||
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_PROPOSED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, attributionId: attribution.id, basisKind: input.basisKind, competitorCount: competitors.length });
|
||||
paymentBatchId: batch.id, attributionId: attribution.id, kind, basisKind: input.basisKind, competitorCount: competitors.length });
|
||||
return attribution;
|
||||
}
|
||||
|
||||
@@ -351,11 +409,14 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
|
||||
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
|
||||
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
|
||||
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by];
|
||||
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by,
|
||||
...(attribution.kind === "failure" ? await reconciliationParticipants(tx, batch) : [])];
|
||||
if (input.decision === "approve") {
|
||||
const { competitors } = await assertAttributableBatch(tx, batch);
|
||||
const claim = await plannedLoanClaim(tx, config, batch);
|
||||
if (!attributionMatches(attribution, claim, competitors)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
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);
|
||||
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.
|
||||
const verifications = await tx.paymentReceiptVerification.findMany({ where: { id: { in: claim.verificationIds } }, include: { attachment: true } });
|
||||
@@ -393,14 +454,14 @@ async function loanAttributionSatisfied(tx, config, batch) {
|
||||
const competitors = await competingLoanBatches(tx, batch, repayment);
|
||||
if (!competitors.length) return true;
|
||||
const claim = await plannedClaimOrNull(tx, config, batch);
|
||||
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement", status: "approved" } });
|
||||
return Boolean(approved && attributionMatches(approved, claim, competitors));
|
||||
}
|
||||
|
||||
async function loanReceiptAttributionState(tx, batch, repayment, config) {
|
||||
const competitors = await competingLoanBatches(tx, batch, repayment);
|
||||
const planned = competitors.length && config ? await displayPlannedClaim(tx, config, batch) : { claim: null, known: false };
|
||||
const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id },
|
||||
const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement" },
|
||||
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], include: { proposer: { select: { name: true } }, reviewer: { select: { name: true } } } });
|
||||
return {
|
||||
required: competitors.length > 0,
|
||||
@@ -430,4 +491,4 @@ function mapRepayment(repayment, batch) {
|
||||
|
||||
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
||||
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied };
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES };
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
|
||||
// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1).
|
||||
// Payment-order exports and bank submissions are separate facts: one export
|
||||
// file may be submitted zero, one or several times, and re-exports reuse the
|
||||
// same customer serial. Failure/termination evidence must cover the reviewed
|
||||
// submission list, never the export count or the attachment count.
|
||||
|
||||
const SUBMISSION_CHANNELS = ["online_banking_import", "manual_entry", "other"];
|
||||
|
||||
function loanPayments() {
|
||||
// Lazy: companyLoanPayments requires this module for failure confirmation.
|
||||
return require("./companyLoanPayments");
|
||||
}
|
||||
|
||||
async function recordLoanPaymentOrderExport(tx, batch, request, generated) {
|
||||
const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } });
|
||||
return tx.paymentOrderExport.create({ data: {
|
||||
payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id,
|
||||
file_sha256: createHash("sha256").update(generated.buffer).digest("hex"),
|
||||
customer_serials: generated.serials, total_amount: generated.totalAmount,
|
||||
} });
|
||||
}
|
||||
|
||||
function assertSubmissionBatch(batch) {
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerBankSubmission(tx, batch, request, input) {
|
||||
assertSubmissionBatch(batch);
|
||||
if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
if (input.exportId) {
|
||||
const record = await tx.paymentOrderExport.findUnique({ where: { id: input.exportId } });
|
||||
if (!record || record.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
} else if (input.channel === "online_banking_import") {
|
||||
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
}
|
||||
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,
|
||||
customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id,
|
||||
} });
|
||||
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel });
|
||||
return submission;
|
||||
}
|
||||
|
||||
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
|
||||
assertSubmissionBatch(batch);
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } });
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") {
|
||||
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
}
|
||||
const withdrawn = await tx.loanBankSubmission.update({ where: { id: submission.id }, data: {
|
||||
status: "withdrawn", withdrawn_reason: reason, withdrawn_at: new Date() } });
|
||||
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_WITHDRAWN", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, submissionId: submission.id });
|
||||
return withdrawn;
|
||||
}
|
||||
|
||||
async function submissionFacts(tx, batch) {
|
||||
const [exports, legacy, submissions] = await Promise.all([
|
||||
tx.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "asc" } }),
|
||||
tx.loanLegacyExportSummary.findUnique({ where: { payment_batch_id: batch.id } }),
|
||||
tx.loanBankSubmission.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ submitted_at: "asc" }, { id: "asc" }] }),
|
||||
]);
|
||||
const active = submissions.filter((item) => item.status === "active");
|
||||
return { exports, legacy, submissions, active,
|
||||
activeIds: active.map((item) => item.id).sort(), allIds: submissions.map((item) => item.id).sort(),
|
||||
exportIds: exports.map((item) => item.id).sort() };
|
||||
}
|
||||
|
||||
const sameIds = (stored, current) => {
|
||||
const left = Array.isArray(stored) ? [...stored].sort() : null;
|
||||
return Boolean(left) && left.length === current.length && left.every((value, index) => value === current[index]);
|
||||
};
|
||||
|
||||
// Bound to every submission row (withdrawn included) and every export: any
|
||||
// registration, withdrawal or export after review invalidates the approval,
|
||||
// so an unreviewed add-then-withdraw cannot revive it.
|
||||
function attestationMatches(attestation, facts) {
|
||||
return Boolean(attestation) && sameIds(attestation.submission_ids, facts.activeIds) &&
|
||||
sameIds(attestation.all_submission_ids, facts.allIds) && sameIds(attestation.export_ids, facts.exportIds);
|
||||
}
|
||||
|
||||
async function activeAttestation(tx, batch) {
|
||||
return tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } });
|
||||
}
|
||||
|
||||
// Finance attests that the active submissions are the complete set for the batch.
|
||||
// A stale open/approved attestation (lists changed) is superseded in the same
|
||||
// transaction so the batch can always move forward.
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
const active = await activeAttestation(tx, batch);
|
||||
if (active) {
|
||||
if (attestationMatches(active, facts)) throw buildBusinessValidationError("loan.submission_attestation_exists", {}, 409);
|
||||
await tx.loanBankSubmissionAttestation.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
|
||||
await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: active.id });
|
||||
}
|
||||
const attestation = await tx.loanBankSubmissionAttestation.create({ data: {
|
||||
payment_batch_id: batch.id, submission_ids: facts.activeIds, all_submission_ids: facts.allIds, export_ids: facts.exportIds,
|
||||
note, proposed_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_PROPOSED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, attestationId: attestation.id, submissionCount: facts.activeIds.length });
|
||||
return attestation;
|
||||
}
|
||||
|
||||
// Independent of everyone who produced the export, the submissions or the
|
||||
// attestation, plus the batch creator, submitter and lender employee.
|
||||
async function reviewSubmissionAttestation(tx, batch, request, attestationId, input) {
|
||||
assertSubmissionBatch(batch);
|
||||
const attestation = await tx.loanBankSubmissionAttestation.findUnique({ where: { id: attestationId } });
|
||||
if (!attestation || attestation.payment_batch_id !== batch.id || attestation.status !== "proposed") {
|
||||
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
}
|
||||
const { readLoanRepayment, activeReviewer } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id,
|
||||
batch.payment_order_last_exported_by, facts.legacy?.last_exported_by,
|
||||
...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)];
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
|
||||
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
|
||||
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.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_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
// S1 temporary gate for failure confirmation (until S3 adds per-submission
|
||||
// terminal evidence): exactly one reviewed submission, at most one export and no
|
||||
// legacy summary. Returns the attested submission ids for the S2 attribution.
|
||||
async function assertSingleAttestedSubmission(tx, batch) {
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
|
||||
if (facts.exports.length > 1 || facts.active.length > 1) throw buildBusinessValidationError("loan.multiple_submissions_unresolved", {}, 409);
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
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);
|
||||
}
|
||||
return facts.activeIds;
|
||||
}
|
||||
|
||||
// Voiding an attachment changes material use, not bank facts. Fail closed: a
|
||||
// voided attachment is ignored only if its current-version recognition shows
|
||||
// either an explicit failure, or a readable transaction that is clearly not
|
||||
// this batch's payment (amount/accounts differ, no pending or ambiguous status).
|
||||
// No verification (voided before OCR), unreadable or ambiguous status, a match,
|
||||
// or a live manual-receipt proposal keeps blocking.
|
||||
const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"];
|
||||
|
||||
async function assertVoidedReceiptsResolved(tx, config, batch) {
|
||||
const { batchCandidates } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bankSubmissionState(tx, batch) {
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const current = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id },
|
||||
orderBy: [{ proposed_at: "desc" }, { id: "desc" }] });
|
||||
return {
|
||||
exports: facts.exports.map((item) => ({ id: item.id, sequence: item.sequence, exportedAt: item.exported_at,
|
||||
exportedBy: item.exported_by, customerSerials: item.customer_serials })),
|
||||
legacy: facts.legacy ? { knownExportCount: facts.legacy.known_export_count, lastExportedAt: facts.legacy.last_exported_at,
|
||||
unknownFacts: facts.legacy.unknown_facts.split(",") } : null,
|
||||
submissions: facts.submissions.map((item) => ({ id: item.id, exportId: item.export_id, channel: item.channel,
|
||||
submittedAt: item.submitted_at, bankInstructionRef: item.bank_instruction_ref, customerSerial: item.customer_serial,
|
||||
note: item.note, status: item.status, registeredBy: item.registered_by })),
|
||||
attestation: current ? { id: current.id, status: current.status, proposedBy: current.proposed_by, reviewedBy: current.reviewed_by,
|
||||
stale: ["proposed", "approved"].includes(current.status) && !attestationMatches(current, facts) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
|
||||
reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts,
|
||||
};
|
||||
@@ -41,7 +41,7 @@ function normalizeStatus(value) {
|
||||
function normalizeBankSlip(fields) {
|
||||
const result = {
|
||||
amount: null, currency: null, payerAccount: null, payeeAccount: null,
|
||||
transactionStatus: null, reference: null, issues: [],
|
||||
transactionStatus: null, transactionStatusRaw: null, reference: null, issues: [],
|
||||
};
|
||||
if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) {
|
||||
result.issues.push("response_invalid");
|
||||
@@ -68,6 +68,15 @@ function normalizeBankSlip(fields) {
|
||||
result.issues.push(`${key}_ambiguous`);
|
||||
} else if (values.length && values[0] !== null) {
|
||||
result[key] = values[0];
|
||||
// The exact bank wording is kept: several distinct bank states share the
|
||||
// normalized "failed", but only some prove that no money left the account.
|
||||
// Several bank wordings normalize to the same value (e.g. 交易失败 and 已撤销
|
||||
// both become "failed"), so the raw wording is kept only when every status
|
||||
// field carries the identical text; otherwise it stays null (fail closed).
|
||||
if (key === "transactionStatus") {
|
||||
const raws = new Set(entries.map((field) => normalizeText(field.Value)));
|
||||
result.transactionStatusRaw = raws.size === 1 ? [...raws][0] : null;
|
||||
}
|
||||
} else {
|
||||
result.issues.push(`${key}_unresolved`);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildVerificationData(config, batch, attachment, recognition) {
|
||||
status: recognition.status, pagesExpected: recognition.pagesExpected,
|
||||
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
|
||||
pages: pages.map((page) => Object.fromEntries(
|
||||
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference", "issues"]
|
||||
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "transactionStatusRaw", "reference", "issues"]
|
||||
.map((key) => [key, page[key]]),
|
||||
)),
|
||||
})),
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
"loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。",
|
||||
"loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。",
|
||||
"loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。",
|
||||
"loan.submission_required": "须先登记本批次实际向银行提交的指令,才能确认提交清单。",
|
||||
"loan.submission_list_unverified": "银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。",
|
||||
"loan.submission_history_unverified": "该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。",
|
||||
"loan.multiple_submissions_unresolved": "该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。",
|
||||
"loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。",
|
||||
"loan.submission_export_invalid": "所选导出记录不属于本批次,或网银导入的提交未关联导出记录。",
|
||||
"loan.submission_attestation_exists": "本批次已有待复核或已通过的提交清单确认,请勿重复提交。",
|
||||
"loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。",
|
||||
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
|
||||
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",
|
||||
|
||||
在新工单中引用
屏蔽一个用户