-- AlterEnum ALTER TYPE "PaymentBatchEntityType" ADD VALUE 'loan_repayment'; -- AlterTable ALTER TABLE "payment_batches" ADD COLUMN "loan_repayment_id" UUID; -- CreateTable CREATE TABLE "loan_lenders" ( "id" UUID NOT NULL, "legal_entity_id" UUID NOT NULL, "name" VARCHAR(120) NOT NULL, "employee_id" UUID, "identity_status" VARCHAR(16) NOT NULL DEFAULT 'unverified', "identity_version" INTEGER NOT NULL DEFAULT 1, "account_version" INTEGER NOT NULL DEFAULT 1, "identity_confirmed_by" UUID, "identity_confirmed_at" TIMESTAMP(3), "identity_confirmation" TEXT, "bank_institution_id" UUID NOT NULL, "bank_name" VARCHAR(200) NOT NULL, "bank_account_encrypted" TEXT NOT NULL, "account_name_encrypted" TEXT NOT NULL, "archived_at" TIMESTAMP(3), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_by" UUID NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL, "updated_by" UUID NOT NULL, "request_key" UUID NOT NULL, CONSTRAINT "loan_lenders_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "company_loans" ( "id" UUID NOT NULL, "loan_no" VARCHAR(64) NOT NULL, "origin_reference" VARCHAR(200) NOT NULL, "legal_entity_id" UUID NOT NULL, "lender_id" UUID NOT NULL, "currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY', "original_principal" DECIMAL(14,2) NOT NULL, "opening_outstanding_principal" DECIMAL(14,2) NOT NULL, "received_date" DATE NOT NULL, "balance_as_of" DATE NOT NULL, "historical_entry" BOOLEAN NOT NULL, "confirmation_status" VARCHAR(16) NOT NULL DEFAULT 'unconfirmed', "version" INTEGER NOT NULL DEFAULT 1, "confirmed_by" UUID, "confirmed_at" TIMESTAMP(3), "accounting_confirmation_ref" VARCHAR(200), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_by" UUID NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL, "updated_by" UUID NOT NULL, "request_key" UUID NOT NULL, CONSTRAINT "company_loans_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "loan_repayments" ( "id" UUID NOT NULL, "repayment_no" VARCHAR(64) NOT NULL, "legal_entity_id" UUID NOT NULL, "lender_id" UUID NOT NULL, "currency_code" VARCHAR(8) NOT NULL DEFAULT 'CNY', "reason" VARCHAR(500) NOT NULL, "amount" DECIMAL(14,2) NOT NULL, "version" INTEGER NOT NULL DEFAULT 1, "submitted_by" UUID NOT NULL, "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "lender_identity_version" INTEGER NOT NULL, "lender_account_version" INTEGER NOT NULL, "payee_snapshot_json" JSONB NOT NULL, "bank_result_state" VARCHAR(24) NOT NULL DEFAULT 'not_sent', "bank_result_ref" VARCHAR(500), "accounting_handoff_by" UUID, "accounting_handoff_at" TIMESTAMP(3), "accounting_handoff_ref" VARCHAR(200), "request_key" UUID NOT NULL, CONSTRAINT "loan_repayments_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "loan_repayment_allocations" ( "id" UUID NOT NULL, "repayment_id" UUID NOT NULL, "loan_id" UUID NOT NULL, "amount" DECIMAL(14,2) NOT NULL, "loan_version" INTEGER NOT NULL, "reservation_state" VARCHAR(16) NOT NULL DEFAULT 'reserved', "settled_at" TIMESTAMP(3), "released_at" TIMESTAMP(3), "release_reason" VARCHAR(500), CONSTRAINT "loan_repayment_allocations_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "loan_evidences" ( "id" UUID NOT NULL, "loan_id" UUID NOT NULL, "kind" VARCHAR(32) NOT NULL, "original_name" VARCHAR(255) NOT NULL, "storage_path" TEXT NOT NULL, "mime_type" VARCHAR(100) NOT NULL, "file_size" INTEGER NOT NULL, "file_sha256" VARCHAR(64) NOT NULL, "governance_status" VARCHAR(16) NOT NULL DEFAULT 'pending', "uploaded_by" UUID NOT NULL, "uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "archived_at" TIMESTAMP(3), "archive_reason" VARCHAR(500), CONSTRAINT "loan_evidences_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE INDEX "loan_lenders_employee_id_idx" ON "loan_lenders"("employee_id"); -- CreateIndex CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_id_key" ON "loan_lenders"("legal_entity_id", "id"); -- CreateIndex CREATE UNIQUE INDEX "loan_lenders_created_by_request_key_key" ON "loan_lenders"("created_by", "request_key"); -- CreateIndex CREATE UNIQUE INDEX "company_loans_loan_no_key" ON "company_loans"("loan_no"); -- CreateIndex CREATE INDEX "company_loans_lender_id_confirmation_status_received_date_idx" ON "company_loans"("lender_id", "confirmation_status", "received_date"); -- CreateIndex CREATE UNIQUE INDEX "company_loans_created_by_request_key_key" ON "company_loans"("created_by", "request_key"); -- CreateIndex CREATE UNIQUE INDEX "loan_repayments_repayment_no_key" ON "loan_repayments"("repayment_no"); -- CreateIndex CREATE INDEX "loan_repayments_lender_id_submitted_at_idx" ON "loan_repayments"("lender_id", "submitted_at"); -- CreateIndex CREATE UNIQUE INDEX "loan_repayments_submitted_by_request_key_key" ON "loan_repayments"("submitted_by", "request_key"); -- CreateIndex CREATE INDEX "loan_repayment_allocations_loan_id_reservation_state_idx" ON "loan_repayment_allocations"("loan_id", "reservation_state"); -- CreateIndex CREATE UNIQUE INDEX "loan_repayment_allocations_repayment_id_loan_id_key" ON "loan_repayment_allocations"("repayment_id", "loan_id"); -- CreateIndex CREATE UNIQUE INDEX "loan_evidences_loan_id_file_sha256_kind_key" ON "loan_evidences"("loan_id", "file_sha256", "kind"); -- AddForeignKey ALTER TABLE "payment_batches" ADD CONSTRAINT "payment_batches_loan_repayment_id_fkey" FOREIGN KEY ("loan_repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_legal_entity_id_fkey" FOREIGN KEY ("legal_entity_id") REFERENCES "legal_entities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_lenders" ADD CONSTRAINT "loan_lenders_bank_institution_id_fkey" FOREIGN KEY ("bank_institution_id") REFERENCES "bank_institutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "company_loans" ADD CONSTRAINT "company_loans_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_repayments" ADD CONSTRAINT "loan_repayments_legal_entity_id_lender_id_fkey" FOREIGN KEY ("legal_entity_id", "lender_id") REFERENCES "loan_lenders"("legal_entity_id", "id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_repayment_id_fkey" FOREIGN KEY ("repayment_id") REFERENCES "loan_repayments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_repayment_allocations" ADD CONSTRAINT "loan_repayment_allocations_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_loan_id_fkey" FOREIGN KEY ("loan_id") REFERENCES "company_loans"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "loan_evidences" ADD CONSTRAINT "loan_evidences_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- A historical incoming reference may be registered only once for this lender. CREATE UNIQUE INDEX "company_loans_legal_entity_id_lender_id_origin_reference_key" ON company_loans(legal_entity_id, lender_id, origin_reference); ALTER TABLE loan_lenders ADD CONSTRAINT loan_lenders_identity_check CHECK ( identity_status IN ('unverified','verified') AND identity_version > 0 AND account_version > 0 AND (identity_status <> 'verified' OR (identity_confirmed_by IS NOT NULL AND identity_confirmed_at IS NOT NULL AND NULLIF(trim(identity_confirmation),'') IS NOT NULL)) ); ALTER TABLE company_loans ADD CONSTRAINT company_loans_balance_check CHECK ( original_principal > 0 AND opening_outstanding_principal >= 0 AND opening_outstanding_principal <= original_principal AND currency_code = 'CNY' AND received_date <= balance_as_of AND version > 0 AND confirmation_status IN ('unconfirmed','confirmed') AND (confirmation_status <> 'confirmed' OR (confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL AND NULLIF(trim(accounting_confirmation_ref),'') IS NOT NULL)) ); 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_success') AND (bank_result_state NOT IN ('confirmed_failed','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)) ); ALTER TABLE loan_repayment_allocations ADD CONSTRAINT loan_allocation_amount_state_check CHECK ( amount > 0 AND loan_version > 0 AND reservation_state IN ('reserved','settled','released') AND (reservation_state <> 'settled' OR settled_at IS NOT NULL) AND (reservation_state <> 'released' OR (released_at IS NOT NULL AND NULLIF(trim(release_reason),'') IS NOT NULL)) ); ALTER TABLE loan_evidences ADD CONSTRAINT loan_evidence_kind_check CHECK ( kind IN ('agreement','incoming','accounting_confirmation','adjustment') AND file_size > 0 AND governance_status IN ('pending','approved','blocked') AND file_sha256 ~ '^[0-9a-f]{64}$' ); ALTER TABLE payment_batches ADD CONSTRAINT payment_batch_loan_source_check CHECK ( (entity_type::text = 'loan_repayment') = (loan_repayment_id IS NOT NULL) ); CREATE UNIQUE INDEX payment_batch_one_active_loan_repayment ON payment_batches(loan_repayment_id) WHERE loan_repayment_id IS NOT NULL AND status <> 'cancelled'; -- Confirmed opening balances are immutable; correcting history needs a separately -- reviewed adjustment, not an UPDATE that silently erases old loan evidence. CREATE FUNCTION guard_confirmed_loan_balance() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.confirmation_status = 'confirmed' AND ( NEW.legal_entity_id, NEW.lender_id, NEW.currency_code, NEW.original_principal, NEW.opening_outstanding_principal, NEW.received_date, NEW.balance_as_of, NEW.historical_entry, NEW.origin_reference, NEW.confirmation_status, NEW.confirmed_by, NEW.confirmed_at, NEW.accounting_confirmation_ref ) IS DISTINCT FROM ( OLD.legal_entity_id, OLD.lender_id, OLD.currency_code, OLD.original_principal, OLD.opening_outstanding_principal, OLD.received_date, OLD.balance_as_of, OLD.historical_entry, OLD.origin_reference, OLD.confirmation_status, OLD.confirmed_by, OLD.confirmed_at, OLD.accounting_confirmation_ref ) THEN RAISE EXCEPTION 'confirmed loan balance is immutable' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER company_loan_confirmed_immutable BEFORE UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION guard_confirmed_loan_balance(); -- Deferred checks validate the complete atomic repayment/batch/allocation write. -- Writers still follow the application lock order; the loan-row locks below also -- serialize aggregate validation across concurrent transactions. CREATE 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')) 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 FUNCTION check_loan_repayment_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE old_id uuid; new_id uuid; BEGIN IF TG_TABLE_NAME='loan_repayments' THEN IF TG_OP<>'INSERT' THEN old_id=OLD.id; END IF; IF TG_OP<>'DELETE' THEN new_id=NEW.id; END IF; ELSIF TG_TABLE_NAME='loan_repayment_allocations' THEN IF TG_OP<>'INSERT' THEN old_id=OLD.repayment_id; END IF; IF TG_OP<>'DELETE' THEN new_id=NEW.repayment_id; END IF; ELSIF TG_TABLE_NAME='payment_batches' THEN IF TG_OP<>'INSERT' THEN old_id=OLD.loan_repayment_id; END IF; IF TG_OP<>'DELETE' THEN new_id=NEW.loan_repayment_id; END IF; ELSE IF TG_OP<>'INSERT' THEN SELECT loan_repayment_id INTO old_id FROM payment_batches WHERE id=OLD.payment_batch_id; END IF; IF TG_OP<>'DELETE' THEN SELECT loan_repayment_id INTO new_id FROM payment_batches WHERE id=NEW.payment_batch_id; END IF; END IF; IF old_id IS NOT NULL THEN PERFORM assert_loan_repayment_integrity(old_id); END IF; IF new_id IS NOT NULL AND new_id IS DISTINCT FROM old_id THEN PERFORM assert_loan_repayment_integrity(new_id); END IF; RETURN NULL; END $$; CREATE CONSTRAINT TRIGGER loan_repayment_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); CREATE CONSTRAINT TRIGGER loan_allocation_integrity AFTER INSERT OR UPDATE OR DELETE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); CREATE CONSTRAINT TRIGGER loan_batch_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batches DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); CREATE CONSTRAINT TRIGGER loan_payment_item_integrity AFTER INSERT OR UPDATE OR DELETE ON payment_batch_items DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_repayment_integrity(); CREATE 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_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 $$; CREATE TRIGGER loan_repayment_history BEFORE UPDATE OR DELETE ON loan_repayments FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history(); CREATE TRIGGER loan_allocation_history BEFORE UPDATE OR DELETE ON loan_repayment_allocations FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_history();