-- S6/S7: append actual bank payments and returns, never issue a payment batch. CREATE TABLE loan_bank_result_events ( id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT, kind VARCHAR(16) NOT NULL CHECK(kind IN ('late_success','return')), parent_id UUID REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT, evidence_id UUID NOT NULL REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT, request_key UUID NOT NULL, proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, payload_encrypted TEXT NOT NULL, fingerprint VARCHAR(64) NOT NULL, key_id VARCHAR(64) NOT NULL, amount DECIMAL(14,2) NOT NULL CHECK(amount>0), snapshot_json JSONB NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','booked','dismissed','rejected','superseded')), reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(1000), correction_evidence_id UUID REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT, UNIQUE(payment_batch_id,request_key), CHECK((kind='return')=(parent_id IS NOT NULL)), CHECK(reviewed_by IS NULL OR reviewed_by<>proposed_by), CHECK(status IN ('proposed','superseded') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>=5)) ); CREATE INDEX loan_bank_result_events_payment_batch_id_kind_idx ON loan_bank_result_events(payment_batch_id,kind); CREATE TABLE loan_supplemental_settlements ( id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT, loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT, bank_amount DECIMAL(14,2) NOT NULL CHECK(bank_amount>0), settled_amount DECIMAL(14,2) NOT NULL CHECK(settled_amount>=0), receivable_amount DECIMAL(14,2) NOT NULL CHECK(receivable_amount>=0), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(event_id,loan_id), CHECK(bank_amount=settled_amount+receivable_amount) ); CREATE TABLE loan_bank_event_claims ( fingerprint VARCHAR(64) PRIMARY KEY, key_id VARCHAR(64) NOT NULL, event_id UUID UNIQUE NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TRIGGER loan_supplemental_history BEFORE UPDATE OR DELETE ON loan_supplemental_settlements FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only(); CREATE TRIGGER loan_bank_event_claim_history BEFORE UPDATE OR DELETE ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only(); CREATE FUNCTION guard_loan_bank_event_history() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP='DELETE' THEN RAISE EXCEPTION 'bank event history retained' USING ERRCODE='23514'; END IF; IF TG_OP='INSERT' THEN IF NEW.status<>'proposed' OR NEW.reviewed_by IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.correction_evidence_id IS NOT NULL THEN RAISE EXCEPTION 'bank event initial state invalid' USING ERRCODE='23514'; END IF; ELSE IF (to_jsonb(NEW)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) OR NOT ((OLD.status='proposed' AND NEW.status IN ('approved','dismissed','rejected','superseded')) OR (OLD.status='approved' AND NEW.status IN ('booked','superseded'))) THEN RAISE EXCEPTION 'bank event immutable' USING ERRCODE='23514'; END IF; IF OLD.status='approved' AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note,NEW.correction_evidence_id) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note,OLD.correction_evidence_id) THEN RAISE EXCEPTION 'bank event review immutable' USING ERRCODE='23514'; END IF; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_bank_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_result_events FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_event_history(); 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','confirmed_not_submitted')) 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 coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=loan_uuid) > (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_supplemental_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE e loan_bank_result_events; lid UUID; total NUMERIC; BEGIN SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.event_id; IF e.kind<>'late_success' OR e.status<>'booked' OR (SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN RAISE EXCEPTION 'supplemental event totals invalid' USING ERRCODE='23514'; END IF; IF EXISTS(SELECT 1 FROM loan_supplemental_settlements s WHERE s.event_id=e.id AND NOT EXISTS ( SELECT 1 FROM loan_repayment_allocations a JOIN payment_batches b ON b.loan_repayment_id=a.repayment_id WHERE b.id=e.payment_batch_id AND a.loan_id=s.loan_id AND a.amount=s.bank_amount AND a.reservation_state='released')) THEN RAISE EXCEPTION 'supplemental source allocation invalid' USING ERRCODE='23514'; END IF; FOR lid IN SELECT loan_id FROM loan_supplemental_settlements WHERE event_id=e.id ORDER BY loan_id LOOP PERFORM id FROM company_loans WHERE id=lid FOR UPDATE; SELECT coalesce(sum(amount),0) INTO total FROM loan_repayment_allocations WHERE loan_id=lid AND reservation_state IN ('reserved','settled'); IF total+(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=lid)> (SELECT opening_outstanding_principal FROM company_loans WHERE id=lid) THEN RAISE EXCEPTION 'supplemental principal exceeded' USING ERRCODE='23514'; END IF; END LOOP; RETURN NEW; END $$; CREATE CONSTRAINT TRIGGER loan_supplemental_integrity AFTER INSERT ON loan_supplemental_settlements DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_supplemental_integrity(); CREATE OR REPLACE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID; BEGIN IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id; ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF; SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1; IF latest.id IS NULL OR EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF; SELECT * INTO b FROM payment_batches WHERE id=bid; rid:=b.loan_repayment_id; IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND (latest.status<>'consumed' OR b.status<>'cancelled') THEN RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514'; END IF; IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE FUNCTION guard_loan_bank_transaction_identity() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(1002,1); IF TG_TABLE_NAME='loan_bank_event_claims' THEN IF EXISTS(SELECT 1 FROM payment_receipt_claims WHERE fingerprint=NEW.fingerprint) OR EXISTS(SELECT 1 FROM payment_receipt_transaction_aliases WHERE fingerprint=NEW.fingerprint) THEN RAISE EXCEPTION 'bank transaction already used' USING ERRCODE='23514'; END IF; ELSE IF EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE fingerprint=NEW.fingerprint) THEN RAISE EXCEPTION 'bank transaction already supplemented' USING ERRCODE='23514'; END IF; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_bank_identity BEFORE INSERT ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity(); CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity(); CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_transaction_aliases FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity(); ALTER TABLE loan_bank_terminal_evidences DROP CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check; ALTER TABLE loan_bank_terminal_evidences ADD CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check CHECK( evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement','late_success','incoming_return')); CREATE OR REPLACE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE bid UUID; b payment_batches; lender UUID; data JSONB; BEGIN data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END; bid := (data->>'payment_batch_id')::uuid; IF bid IS NULL THEN SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid; END IF; SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE; IF b.entity_type='loan_repayment' THEN IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') AND NOT (TG_OP='INSERT' AND TG_TABLE_NAME='loan_bank_terminal_evidences') THEN RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514'; END IF; IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id; PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE; END IF; INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1) ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END $$; -- Business application cannot bypass atomic fact/claim/ledger consistency. CREATE FUNCTION check_loan_bank_event_integrity() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE e loan_bank_result_events; parent loan_bank_result_events; total NUMERIC; expected NUMERIC; BEGIN SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.id; IF e.status='booked' AND NOT EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE event_id=e.id AND fingerprint=e.fingerprint AND key_id=e.key_id) THEN RAISE EXCEPTION 'bank event requires unique claim' USING ERRCODE='23514'; END IF; IF e.kind='late_success' THEN IF NOT EXISTS(SELECT 1 FROM loan_freeze_reasons WHERE payment_batch_id=e.payment_batch_id AND kind='late_success' AND source_key=e.id::text) THEN RAISE EXCEPTION 'late event requires recorded freeze' USING ERRCODE='23514'; END IF; IF e.status='booked' AND (SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN RAISE EXCEPTION 'late event requires complete supplemental allocation' USING ERRCODE='23514'; END IF; ELSE SELECT * INTO parent FROM loan_bank_result_events WHERE id=e.parent_id; IF parent.kind<>'late_success' OR parent.payment_batch_id<>e.payment_batch_id OR parent.status<>'booked' THEN RAISE EXCEPTION 'return parent invalid' USING ERRCODE='23514'; END IF; SELECT coalesce(sum(receivable_amount),0) INTO expected FROM loan_supplemental_settlements WHERE event_id=e.parent_id; SELECT coalesce(sum(amount),0) INTO total FROM loan_bank_result_events WHERE parent_id=e.parent_id AND kind='return' AND status='booked'; IF total>expected THEN RAISE EXCEPTION 'return exceeds receivable' USING ERRCODE='23514'; END IF; END IF; RETURN NEW; END $$; CREATE CONSTRAINT TRIGGER loan_bank_event_integrity AFTER INSERT OR UPDATE ON loan_bank_result_events DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_bank_event_integrity(); CREATE FUNCTION check_loan_manual_release() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals; revision BIGINT; BEGIN IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF; SELECT * INTO r FROM loan_repayments WHERE id=NEW.repayment_id; IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF; SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id; IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF; SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1; SELECT m.revision INTO revision FROM loan_release_material_revisions m WHERE payment_batch_id=bid; IF p.id IS NULL OR p.status<>'approved' OR (p.snapshot_json->>'revision')::bigint IS DISTINCT FROM COALESCE(revision,0) OR (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN RAISE EXCEPTION 'manual release requires current double review' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE CONSTRAINT TRIGGER loan_manual_release AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_release(); CREATE FUNCTION protect_reviewed_bank_event_evidence() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE (evidence_id=OLD.id AND status IN ('approved','booked')) OR (correction_evidence_id=OLD.id AND status='dismissed')) THEN RAISE EXCEPTION 'reviewed bank event evidence retained' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER reviewed_bank_event_evidence BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION protect_reviewed_bank_event_evidence(); CREATE FUNCTION check_loan_release_against_late_fact() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE bid UUID; BEGIN IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF; SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status IN ('proposed','approved')) AND NOT EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RAISE EXCEPTION 'late success blocks original release conclusion' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE CONSTRAINT TRIGGER loan_release_late_fact AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_release_against_late_fact();