借款对账 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]>
这个提交包含在:
2026-10-07 16:50:17 +08:00
共同撰写人 Claude Opus 5.5
父节点 db28706177
当前提交 14aee82d5c
共修改 12 个文件,包含 1299 行新增和 81 行删除
@@ -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));