-- Receipt-to-batch attribution for loan repayments. When another same-lender, -- same-amount loan batch was ever exported or had receipts, a matched receipt -- alone does not prove which batch it paid; settlement then requires an -- independently reviewed attribution bound to the exact transaction fingerprint -- and to the receipt evidence (verification and manual-proposal ids) reviewed. -- CreateTable CREATE TABLE "loan_receipt_attributions" ( "id" UUID NOT NULL, "payment_batch_id" UUID NOT NULL, "fingerprint" VARCHAR(64) NOT NULL, "key_id" VARCHAR(64) NOT NULL, "verification_ids" JSONB NOT NULL, "manual_proposal_ids" JSONB NOT NULL, "competitor_batch_ids" JSONB NOT NULL, "basis_kind" VARCHAR(32) NOT NULL, "basis_note" VARCHAR(1000) NOT NULL, "exclusion_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_receipt_attributions_pkey" PRIMARY KEY ("id"), CONSTRAINT "loan_receipt_attributions_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')), CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN ('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved')), CONSTRAINT "loan_receipt_attributions_notes_check" CHECK ( char_length(btrim("basis_note")) >= 10 AND char_length(btrim("exclusion_note")) >= 10), CONSTRAINT "loan_receipt_attributions_evidence_check" CHECK ( jsonb_typeof("verification_ids") = 'array' AND jsonb_array_length("verification_ids") > 0 AND jsonb_typeof("manual_proposal_ids") = 'array'), CONSTRAINT "loan_receipt_attributions_competitors_check" CHECK ( jsonb_typeof("competitor_batch_ids") = 'array' AND jsonb_array_length("competitor_batch_ids") > 0), CONSTRAINT "loan_receipt_attributions_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_receipt_attributions_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)), CONSTRAINT "loan_receipt_attributions_independent_check" CHECK ( "reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by") ); -- CreateIndex CREATE INDEX "loan_receipt_attributions_batch_fingerprint_idx" ON "loan_receipt_attributions"("payment_batch_id", "fingerprint"); -- At most one open or approved attribution per batch; rejected and superseded rows stay as history. CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch" ON "loan_receipt_attributions"("payment_batch_id") WHERE "status" IN ('proposed', 'approved'); -- AddForeignKey ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- History is append-only: proposed -> approved/rejected/superseded, approved -> -- superseded (when the receipt or competitor set changed); nothing else, no deletes. CREATE 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" THEN RAISE EXCEPTION 'loan receipt attribution proposal is immutable'; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_receipt_attribution_history BEFORE UPDATE OR DELETE ON "loan_receipt_attributions" FOR EACH ROW EXECUTE FUNCTION guard_loan_receipt_attribution_history();