-- SR: append recovery decisions without reopening the original bank outcome. CREATE TABLE loan_release_recoveries ( id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT, sequence INTEGER NOT NULL CHECK(sequence > 0), previous_id UUID REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT, request_key UUID NOT NULL, original_state VARCHAR(32) NOT NULL CHECK(original_state IN ('confirmed_failed','confirmed_not_paid')), original_ref TEXT NOT NULL CHECK(length(trim(original_ref)) > 0), reason VARCHAR(1000) NOT NULL CHECK(length(trim(reason))>=5), started_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(16) NOT NULL DEFAULT 'collecting' CHECK(status IN ('collecting','proposed','approved','rejected','withdrawn','superseded','consumed')), submitted_by UUID REFERENCES employees(id) ON DELETE RESTRICT, submitted_at TIMESTAMP(3), snapshot_json JSONB, reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(500), ended_at TIMESTAMP(3), cancel_request_key UUID, consumed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, UNIQUE(payment_batch_id,sequence), UNIQUE(payment_batch_id,request_key), CHECK(submitted_by IS NULL OR submitted_by=started_by), CHECK(reviewed_by IS NULL OR (reviewed_by<>started_by AND reviewed_by<>submitted_by)), CHECK(status NOT IN ('proposed','approved','rejected','consumed') OR (snapshot_json IS NOT NULL AND submitted_by IS NOT NULL AND submitted_at IS NOT NULL)), CHECK(status NOT IN ('approved','rejected','consumed') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>0)), CHECK(status<>'consumed' OR (cancel_request_key IS NOT NULL AND consumed_by IS NOT NULL)), CHECK(status NOT IN ('withdrawn','superseded','rejected','consumed') OR ended_at IS NOT NULL) ); CREATE UNIQUE INDEX loan_release_recoveries_open ON loan_release_recoveries(payment_batch_id) WHERE status IN ('collecting','proposed','approved'); CREATE TABLE loan_release_recovery_events ( id UUID PRIMARY KEY, recovery_id UUID NOT NULL REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT, action VARCHAR(32) NOT NULL, actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, note VARCHAR(1000) NOT NULL, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX loan_release_recovery_events_recovery_id_idx ON loan_release_recovery_events(recovery_id); CREATE TABLE loan_release_material_revisions ( payment_batch_id UUID PRIMARY KEY REFERENCES payment_batches(id) ON DELETE RESTRICT, revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0) ); CREATE 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['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['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 TRIGGER loan_release_recovery_history BEFORE INSERT OR UPDATE OR DELETE ON loan_release_recoveries FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history(); CREATE TRIGGER loan_release_recovery_event_history BEFORE UPDATE OR DELETE ON loan_release_recovery_events FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history(); -- All receipt/terminal/list writers share the parent lock and monotonically -- advance material revisions, including background OCR and void/reversal ABA. CREATE 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') 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 $$; CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_order_exports FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submissions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submission_attestations FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_facts FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_receipt_attributions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_batch_receipt_attachments FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_verifications FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_manual_proposals FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material(); -- Deferred: consumption, allocations and batch cancellation are one commit. CREATE 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 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 CONSTRAINT TRIGGER loan_recovery_release_check AFTER INSERT OR UPDATE ON loan_release_recoveries DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release(); CREATE CONSTRAINT TRIGGER loan_recovery_allocation_check AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release(); CREATE FUNCTION guard_loan_material_revision() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP='DELETE' OR NEW.payment_batch_id IS DISTINCT FROM OLD.payment_batch_id OR NEW.revision<>OLD.revision+1 THEN RAISE EXCEPTION 'material revision must increase once' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER loan_material_revision_history BEFORE UPDATE OR DELETE ON loan_release_material_revisions FOR EACH ROW EXECUTE FUNCTION guard_loan_material_revision();