公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
309 行
22 KiB
PL/PgSQL
309 行
22 KiB
PL/PgSQL
-- 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 $$;
|