-- 09-17 company-loan-principal-repayment: explicit related-party decisions. -- Additive only. Old rows remain independent; never manufacture authority. CREATE TABLE loan_related_authorizations ( id UUID PRIMARY KEY, legal_entity_id UUID NOT NULL, lender_id UUID NOT NULL, approver_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, identity_version INTEGER NOT NULL CHECK(identity_version>0), version INTEGER NOT NULL CHECK(version>0), basis VARCHAR(1000) NOT NULL CHECK(length(btrim(basis))>=10), proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, request_key UUID NOT NULL, CONSTRAINT loan_related_authorization_actors CHECK(proposed_by<>approver_id), CONSTRAINT loan_related_authorization_lender FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT, UNIQUE(lender_id,version), UNIQUE(proposed_by,request_key) ); CREATE TABLE loan_related_authorization_loans ( authorization_id UUID NOT NULL REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT, loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT, PRIMARY KEY(authorization_id,loan_id) ); CREATE TABLE loan_related_authorization_events ( id UUID PRIMARY KEY, authorization_id UUID NOT NULL REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT, sequence INTEGER NOT NULL CHECK(sequence>0), action VARCHAR(24) NOT NULL CHECK(action IN ('confirm','stop_payment','stop_reconcile')), payment_enabled BOOLEAN NOT NULL, reconcile_enabled BOOLEAN NOT NULL, actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(authorization_id,sequence) ); CREATE FUNCTION guard_loan_related_authority() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE a loan_related_authorizations; p loan_related_authorization_events; l loan_lenders; c company_loans; BEGIN IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'related authority history is immutable' USING ERRCODE='23514'; END IF; IF TG_TABLE_NAME='loan_related_authorizations' THEN SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE; IF NEW.approver_id IS DISTINCT FROM l.employee_id OR l.identity_status<>'verified' OR NEW.identity_version<>l.identity_version OR NEW.version<>COALESCE((SELECT max(version)+1 FROM loan_related_authorizations WHERE lender_id=l.id),1) THEN RAISE EXCEPTION 'invalid related authority identity' USING ERRCODE='23514'; END IF; ELSE SELECT * INTO a FROM loan_related_authorizations WHERE id=NEW.authorization_id; PERFORM id FROM loan_lenders WHERE id=a.lender_id FOR UPDATE; IF TG_TABLE_NAME='loan_related_authorization_loans' THEN SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id; IF EXISTS(SELECT 1 FROM loan_related_authorization_events WHERE authorization_id=a.id) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR c.confirmation_status<>'confirmed' THEN RAISE EXCEPTION 'invalid related authority scope' USING ERRCODE='23514'; END IF; ELSE SELECT * INTO p FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1; IF NEW.sequence<>COALESCE(p.sequence,0)+1 THEN RAISE EXCEPTION 'invalid authority event sequence' USING ERRCODE='23514'; END IF; IF p.id IS NULL THEN SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id; IF NEW.action<>'confirm' OR NEW.actor_id<>a.approver_id OR NOT NEW.payment_enabled OR NOT NEW.reconcile_enabled OR l.employee_id IS DISTINCT FROM a.approver_id OR l.identity_version<>a.identity_version OR NOT EXISTS(SELECT 1 FROM loan_related_authorization_loans WHERE authorization_id=a.id) OR EXISTS(SELECT 1 FROM loan_related_authorizations newer JOIN loan_related_authorization_events e ON e.authorization_id=newer.id AND e.action='confirm' WHERE newer.lender_id=a.lender_id AND newer.version>=a.version) THEN RAISE EXCEPTION 'invalid authority confirmation' USING ERRCODE='23514'; END IF; ELSIF EXISTS(SELECT 1 FROM loan_related_authorizations newer JOIN loan_related_authorization_events v ON v.authorization_id=newer.id AND v.action='confirm' WHERE newer.lender_id=a.lender_id AND newer.version>a.version) OR NOT ((NEW.action='stop_payment' AND p.payment_enabled AND NOT NEW.payment_enabled AND NEW.reconcile_enabled=p.reconcile_enabled) OR (NEW.action='stop_reconcile' AND p.reconcile_enabled AND NOT NEW.reconcile_enabled AND NEW.payment_enabled=p.payment_enabled)) THEN RAISE EXCEPTION 'authority can only stop; renewal needs new version' USING ERRCODE='23514'; END IF; END IF; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_related_authority_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorizations FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority(); CREATE TRIGGER loan_related_authority_scope_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorization_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority(); CREATE TRIGGER loan_related_authority_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorization_events FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority(); ALTER TABLE loan_repayments ADD COLUMN approval_mode VARCHAR(16) NOT NULL DEFAULT 'independent', ADD COLUMN authorization_id UUID REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT, ADD COLUMN approval_context JSONB, ADD CONSTRAINT loan_repayment_approval_mode CHECK( (approval_mode='independent' AND authorization_id IS NULL AND approval_context IS NULL) OR (approval_mode='related_party' AND authorization_id IS NOT NULL AND approval_context IS NOT NULL AND jsonb_typeof(approval_context)='object') ); DO $$ DECLARE t text; BEGIN FOREACH t IN ARRAY ARRAY['payment_batches','loan_repayments','loan_receipt_attributions','loan_bank_submission_attestations', 'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','payment_receipt_manual_proposals'] LOOP EXECUTE format('ALTER TABLE %I ADD COLUMN loan_approval_mode VARCHAR(16) NOT NULL DEFAULT %L, ADD COLUMN loan_authorization_id UUID REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT, ADD COLUMN loan_approval_context JSONB',t,'independent'); EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I CHECK ((loan_approval_mode=%L AND loan_authorization_id IS NULL AND loan_approval_context IS NULL) OR (loan_approval_mode=%L AND loan_authorization_id IS NOT NULL AND loan_approval_context IS NOT NULL AND jsonb_typeof(loan_approval_context)=%L))', t,t||'_related_mode_check','independent','related_party','object'); END LOOP; END $$; CREATE FUNCTION guard_loan_repayment_approval_context() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE a loan_related_authorizations; e loan_related_authorization_events; latest UUID; BEGIN IF TG_OP='UPDATE' THEN IF (NEW.approval_mode,NEW.authorization_id,NEW.approval_context) IS DISTINCT FROM (OLD.approval_mode,OLD.authorization_id,OLD.approval_context) THEN RAISE EXCEPTION 'repayment approval mode is frozen' USING ERRCODE='23514'; END IF; ELSE IF NEW.approval_mode='related_party' THEN IF jsonb_typeof(NEW.approval_context->'loanIds') IS DISTINCT FROM 'array' THEN RAISE EXCEPTION 'invalid related scope format' USING ERRCODE='23514'; END IF; PERFORM id FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE; SELECT * INTO a FROM loan_related_authorizations WHERE id=NEW.authorization_id; SELECT * INTO e FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1; SELECT g.id INTO latest FROM loan_related_authorizations g WHERE g.lender_id=NEW.lender_id AND EXISTS(SELECT 1 FROM loan_related_authorization_events v WHERE v.authorization_id=g.id AND v.action='confirm') ORDER BY g.version DESC LIMIT 1; IF NOT COALESCE(a.id=latest AND a.lender_id=NEW.lender_id AND a.legal_entity_id=NEW.legal_entity_id AND a.approver_id<>NEW.submitted_by AND e.payment_enabled AND a.identity_version=NEW.lender_identity_version AND NEW.approval_context->>'version'='1' AND NEW.approval_context->>'approverId'=a.approver_id::text AND NEW.approval_context->>'lenderId'=NEW.lender_id::text AND NEW.approval_context->>'legalEntityId'=NEW.legal_entity_id::text AND NEW.approval_context->>'authorizationEventId'=e.id::text AND NEW.approval_context->>'authorizationVersion'=a.version::text AND NEW.approval_context->>'payeeConflictWaived'='true' AND jsonb_array_length(NEW.approval_context->'loanIds')>0,FALSE) THEN RAISE EXCEPTION 'invalid repayment related authority' USING ERRCODE='23514'; END IF; END IF; END IF; IF NEW.approval_mode='related_party' AND NEW.bank_result_state='confirmed_not_submitted' THEN RAISE EXCEPTION 'related repayment does not support manual no-submission' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_repayment_approval_context BEFORE INSERT OR UPDATE ON loan_repayments FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_approval_context(); CREATE FUNCTION check_loan_related_allocations() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE r loan_repayments; rid UUID; BEGIN IF TG_TABLE_NAME='loan_repayments' THEN rid:=NEW.id; ELSE rid:=NEW.repayment_id; END IF; SELECT * INTO r FROM loan_repayments WHERE id=rid; IF r.approval_mode='related_party' AND ( (SELECT count(*) FROM loan_repayment_allocations WHERE repayment_id=rid)<>jsonb_array_length(r.approval_context->'loanIds') OR EXISTS(SELECT 1 FROM loan_repayment_allocations x WHERE x.repayment_id=rid AND ( NOT (r.approval_context->'loanIds' ? x.loan_id::text) OR NOT EXISTS( SELECT 1 FROM loan_related_authorization_loans s WHERE s.authorization_id=r.authorization_id AND s.loan_id=x.loan_id)))) THEN RAISE EXCEPTION 'related allocation outside frozen authority' USING ERRCODE='23514'; END IF; RETURN NULL; END $$; CREATE CONSTRAINT TRIGGER loan_related_allocation_scope AFTER INSERT OR UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_related_allocations(); CREATE CONSTRAINT TRIGGER loan_related_repayment_scope AFTER INSERT OR UPDATE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_related_allocations(); -- The same context is retained on every decision record and checked again by -- final consumers. New contexts must name an effective event, target and actor. CREATE FUNCTION guard_loan_decision_context() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE j JSONB:=to_jsonb(NEW); o JSONB; ctx JSONB:=j->'loan_approval_context'; bid UUID; r loan_repayments; a loan_related_authorizations; e loan_related_authorization_events; needs_decision BOOLEAN; actor UUID; BEGIN IF TG_TABLE_NAME='payment_batches' AND j->>'loan_approval_mode'='related_party' THEN IF NOT COALESCE(ctx->>'decision' IN ('approve','reject'),FALSE) OR (ctx->>'decision'='reject' AND (j->>'status' NOT IN ('draft','cancelled') OR length(btrim(COALESCE(j->>'review_comment',''))) < 2)) THEN RAISE EXCEPTION 'returned payment cannot be paid' USING ERRCODE='23514'; END IF; END IF; IF TG_OP='UPDATE' THEN o:=to_jsonb(OLD); IF o->'loan_approval_context' IS DISTINCT FROM 'null'::jsonb AND o->'loan_approval_context' IS NOT NULL THEN IF (j->'loan_approval_context',j->'loan_approval_mode',j->'loan_authorization_id',j->'reviewed_by',j->'reviewed_at') IS DISTINCT FROM (o->'loan_approval_context',o->'loan_approval_mode',o->'loan_authorization_id',o->'reviewed_by',o->'reviewed_at') THEN RAISE EXCEPTION 'loan decision context immutable' USING ERRCODE='23514'; END IF; IF TG_TABLE_NAME='payment_batches' AND j->'review_comment' IS DISTINCT FROM o->'review_comment' THEN RAISE EXCEPTION 'loan payment decision reason immutable' USING ERRCODE='23514'; END IF; RETURN NEW; END IF; END IF; IF TG_TABLE_NAME='loan_repayments' THEN r:=NEW; SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id; needs_decision:=r.bank_result_state IN ('confirmed_failed','confirmed_not_paid'); ELSIF TG_TABLE_NAME='payment_batches' THEN bid:=NEW.id; SELECT * INTO r FROM loan_repayments WHERE id=NEW.loan_repayment_id; needs_decision:=NEW.reviewed_by IS NOT NULL; ELSE bid:=(j->>'payment_batch_id')::uuid; IF bid IS NULL THEN SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(j->>'attachment_id')::uuid; END IF; SELECT p.* INTO r FROM loan_repayments p JOIN payment_batches b ON b.loan_repayment_id=p.id WHERE b.id=bid; needs_decision:=(j->>'reviewed_by') IS NOT NULL; END IF; IF r.approval_mode IS DISTINCT FROM 'related_party' THEN IF j->>'loan_approval_mode'<>'independent' THEN RAISE EXCEPTION 'independent record cannot carry related approval' USING ERRCODE='23514'; END IF; RETURN NEW; END IF; IF NOT needs_decision AND (ctx IS NULL OR ctx='null'::jsonb) THEN RETURN NEW; END IF; IF NOT needs_decision OR ctx IS NULL OR ctx='null'::jsonb OR j->>'loan_approval_mode'<>'related_party' THEN RAISE EXCEPTION 'missing related decision context' USING ERRCODE='23514'; END IF; IF jsonb_typeof(ctx->'loanIds') IS DISTINCT FROM 'array' OR jsonb_typeof(ctx->'conflictIds') IS DISTINCT FROM 'array' THEN RAISE EXCEPTION 'invalid related decision format' USING ERRCODE='23514'; END IF; PERFORM id FROM loan_lenders WHERE id=r.lender_id FOR UPDATE; SELECT * INTO a FROM loan_related_authorizations WHERE id=(j->>'loan_authorization_id')::uuid; SELECT * INTO e FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1; actor:=COALESCE((j->>'reviewed_by')::uuid,(ctx->>'approverId')::uuid); IF NOT COALESCE(a.lender_id=r.lender_id AND a.legal_entity_id=r.legal_entity_id AND a.approver_id=actor AND ctx->>'approverId'=actor::text AND actor::text=r.approval_context->>'approverId' AND actor<>r.submitted_by AND NOT EXISTS(SELECT 1 FROM payment_batches b WHERE b.id=bid AND b.created_by=actor) AND ctx->>'purpose'=CASE WHEN TG_TABLE_NAME='payment_batches' THEN 'payment' ELSE 'reconcile' END AND ctx->>'version'='1' AND ctx->>'targetId'=j->>'id' AND ctx->>'batchId'=bid::text AND ctx->>'repaymentId'=r.id::text AND ctx->>'lenderId'=r.lender_id::text AND ctx->>'legalEntityId'=r.legal_entity_id::text AND ctx->>'authorizationVersion'=a.version::text AND ctx->>'authorizationEventId'=e.id::text AND ctx->>'payeeConflictWaived'='true' AND ctx->'loanIds'=r.approval_context->'loanIds' AND NOT (ctx->'conflictIds' ? actor::text) AND ((ctx->>'purpose'='payment' AND e.payment_enabled) OR (ctx->>'purpose'='reconcile' AND e.reconcile_enabled)) AND NOT EXISTS(SELECT 1 FROM loan_repayment_allocations x WHERE x.repayment_id=r.id AND NOT EXISTS( SELECT 1 FROM loan_related_authorization_loans s WHERE s.authorization_id=a.id AND s.loan_id=x.loan_id)) AND NOT EXISTS(SELECT 1 FROM loan_related_authorizations n WHERE n.lender_id=a.lender_id AND n.version>a.version AND EXISTS(SELECT 1 FROM loan_related_authorization_events v WHERE v.authorization_id=n.id AND v.action='confirm')),FALSE) THEN RAISE EXCEPTION 'invalid related decision authority' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; DO $$ DECLARE t text; BEGIN FOREACH t IN ARRAY ARRAY['payment_batches','loan_repayments','loan_receipt_attributions','loan_bank_submission_attestations', 'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','payment_receipt_manual_proposals'] LOOP EXECUTE format('CREATE TRIGGER loan_decision_context BEFORE INSERT OR UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION guard_loan_decision_context()',t); END LOOP; END $$; CREATE FUNCTION guard_related_loan_maker() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE j JSONB:=to_jsonb(NEW); bid UUID; r loan_repayments; actor UUID; BEGIN IF TG_TABLE_NAME='payment_batches' THEN bid:=NEW.id; SELECT * INTO r FROM loan_repayments WHERE id=NEW.loan_repayment_id; actor:=NEW.created_by; ELSE bid:=(j->>'payment_batch_id')::uuid; IF bid IS NULL THEN SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(j->>'attachment_id')::uuid; END IF; SELECT p.* INTO r FROM loan_repayments p JOIN payment_batches b ON b.loan_repayment_id=p.id WHERE b.id=bid; actor:=COALESCE((j->>'proposed_by')::uuid,(j->>'registered_by')::uuid,(j->>'uploaded_by')::uuid,(j->>'exported_by')::uuid,(j->>'started_by')::uuid); END IF; IF r.approval_mode='related_party' AND (TG_TABLE_NAME='loan_not_submitted_proposals' OR actor::text=r.approval_context->>'approverId') THEN RAISE EXCEPTION 'related approver cannot be a maker or use manual no-submission' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; DO $$ DECLARE t text; BEGIN FOREACH t IN ARRAY ARRAY['payment_batches','payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments', 'payment_receipt_manual_proposals','loan_receipt_attributions','loan_bank_submission_attestations','loan_bank_terminal_evidences', 'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','loan_not_submitted_proposals'] LOOP EXECUTE format('CREATE TRIGGER related_loan_maker BEFORE INSERT ON %I FOR EACH ROW EXECUTE FUNCTION guard_related_loan_maker()',t); END LOOP; END $$; -- Retain existing state/identity/evidence protections, allowing only the new -- context fields on the first review. The separate trigger makes them immutable. CREATE OR REPLACE FUNCTION guard_loan_release_recovery_history() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE b payment_batches; r loan_repayments; BEGIN IF TG_OP='DELETE' OR TG_TABLE_NAME='loan_release_recovery_events' THEN RAISE EXCEPTION 'recovery history is immutable' USING ERRCODE='23514'; END IF; IF TG_OP='INSERT' THEN SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE; SELECT * INTO r FROM loan_repayments WHERE id=b.loan_repayment_id; IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') OR NEW.original_state IS DISTINCT FROM r.bank_result_state OR NEW.original_ref IS DISTINCT FROM r.bank_result_ref OR NEW.status<>'collecting' OR NEW.snapshot_json IS NOT NULL OR NEW.reviewed_by IS NOT NULL OR NEW.submitted_by IS NOT NULL OR NEW.submitted_at IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.ended_at IS NOT NULL OR NEW.cancel_request_key IS NOT NULL OR NEW.consumed_by IS NOT NULL OR NOT EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id) OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state<>'reserved') THEN RAISE EXCEPTION 'invalid recovery source' USING ERRCODE='23514'; END IF; IF NEW.sequence <> COALESCE((SELECT max(sequence)+1 FROM loan_release_recoveries WHERE payment_batch_id=b.id),1) OR NEW.previous_id IS DISTINCT FROM (SELECT id FROM loan_release_recoveries WHERE payment_batch_id=b.id ORDER BY sequence DESC LIMIT 1) THEN RAISE EXCEPTION 'invalid recovery predecessor' USING ERRCODE='23514'; END IF; RETURN NEW; END IF; IF (to_jsonb(NEW)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) OR NOT ((OLD.status='collecting' AND NEW.status IN ('proposed','withdrawn')) OR (OLD.status='proposed' AND NEW.status IN ('approved','rejected','withdrawn','superseded')) OR (OLD.status='approved' AND NEW.status IN ('superseded','consumed'))) THEN RAISE EXCEPTION 'invalid recovery transition' USING ERRCODE='23514'; END IF; IF NOT (OLD.status='collecting' AND NEW.status='proposed') AND (NEW.submitted_by,NEW.submitted_at,NEW.snapshot_json) IS DISTINCT FROM (OLD.submitted_by,OLD.submitted_at,OLD.snapshot_json) THEN RAISE EXCEPTION 'immutable recovery submission' USING ERRCODE='23514'; END IF; IF NOT (OLD.status='proposed' AND NEW.status IN ('approved','rejected')) AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note) THEN RAISE EXCEPTION 'immutable recovery decision' USING ERRCODE='23514'; END IF; IF NEW.status<>'consumed' AND (NEW.cancel_request_key,NEW.consumed_by) IS DISTINCT FROM (OLD.cancel_request_key,OLD.consumed_by) THEN RAISE EXCEPTION 'invalid recovery consumption' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE OR REPLACE 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['loan_approval_mode','loan_authorization_id','loan_approval_context','status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','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 $$;