-- 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();