-- N5-C: lender's statement is separate from company-representative authority. -- Additive only; no historical acknowledgement or balance is manufactured. CREATE TABLE loan_lender_acknowledgements ( id UUID PRIMARY KEY, legal_entity_id UUID NOT NULL, lender_id UUID NOT NULL, employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, identity_version INTEGER NOT NULL CHECK(identity_version>0), account_version INTEGER NOT NULL CHECK(account_version>0), lender_name VARCHAR(120) NOT NULL, company_name VARCHAR(200) NOT NULL, loan_ids UUID[] NOT NULL CHECK(cardinality(loan_ids) BETWEEN 1 AND 50), content_hash VARCHAR(64) NOT NULL CHECK(content_hash ~ '^[a-f0-9]{64}$'), request_hash VARCHAR(64) NOT NULL CHECK(request_hash ~ '^[a-f0-9]{64}$'), reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2), 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, invalidated_at TIMESTAMP(3), invalid_reason VARCHAR(40), CHECK(proposed_by<>employee_id), CHECK((invalidated_at IS NULL)=(invalid_reason IS NULL)), FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT, UNIQUE(proposed_by,request_key) ); CREATE INDEX loan_lender_acknowledgements_employee_id_proposed_at_idx ON loan_lender_acknowledgements(employee_id,proposed_at); CREATE INDEX loan_lender_acknowledgements_lender_id_proposed_at_idx ON loan_lender_acknowledgements(lender_id,proposed_at); CREATE TABLE loan_lender_acknowledgement_loans ( acknowledgement_id UUID NOT NULL REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT, loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT, snapshot_json JSONB NOT NULL, PRIMARY KEY(acknowledgement_id,loan_id) ); CREATE INDEX loan_lender_acknowledgement_loans_loan_id_idx ON loan_lender_acknowledgement_loans(loan_id); CREATE TABLE loan_lender_acknowledgement_decisions ( acknowledgement_id UUID PRIMARY KEY REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT, action VARCHAR(16) NOT NULL CHECK(action IN ('confirm','return')), actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2), original_checked BOOLEAN NOT NULL, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK(action<>'confirm' OR original_checked) ); -- One canonical projection, excluding changing payment balances and ordinary -- row versions. Adding accounting evidence / confirming balance keeps it valid. CREATE FUNCTION loan_lender_ack_snapshot(loan_id_arg UUID) RETURNS JSONB LANGUAGE sql STABLE AS $$ SELECT jsonb_build_object( 'loanId',l.id,'loanNo',l.loan_no,'lenderId',l.lender_id,'legalEntityId',l.legal_entity_id, 'originReference',l.origin_reference,'currency',l.currency_code, 'originalPrincipal',l.original_principal::text, 'openingOutstandingPrincipal',l.opening_outstanding_principal::text, 'receivedDate',to_char(l.received_date,'YYYY-MM-DD'), 'balanceAsOf',to_char(l.balance_as_of,'YYYY-MM-DD'),'historicalEntry',l.historical_entry, 'originals',COALESCE((SELECT jsonb_agg(jsonb_build_object( 'id',e.id,'sha256',e.file_sha256,'originalName',e.original_name, 'fileSize',e.file_size,'mimeType',e.mime_type) ORDER BY e.id) FROM loan_evidences e WHERE e.loan_id=l.id AND e.kind='incoming' AND e.archived_at IS NULL AND e.governance_status='approved'),'[]'::jsonb)) FROM company_loans l WHERE l.id=loan_id_arg $$; CREATE FUNCTION guard_loan_lender_ack_history() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE a loan_lender_acknowledgements; l loan_lenders; c company_loans; BEGIN IF TG_TABLE_NAME='loan_lender_acknowledgements' AND TG_OP='UPDATE' THEN IF OLD.invalidated_at IS NOT NULL OR NEW.invalidated_at IS NULL OR NEW.invalid_reason IS NULL OR (to_jsonb(NEW)-'invalidated_at'-'invalid_reason') IS DISTINCT FROM (to_jsonb(OLD)-'invalidated_at'-'invalid_reason') THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF; RETURN NEW; END IF; IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF; IF TG_TABLE_NAME='loan_lender_acknowledgements' THEN SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE; IF l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM NEW.employee_id OR l.identity_version<>NEW.identity_version OR l.account_version<>NEW.account_version OR l.name<>NEW.lender_name OR NEW.company_name IS DISTINCT FROM (SELECT name FROM legal_entities WHERE id=l.legal_entity_id) OR NEW.invalidated_at IS NOT NULL OR NEW.loan_ids IS DISTINCT FROM ARRAY(SELECT DISTINCT unnest(NEW.loan_ids) ORDER BY 1) THEN RAISE EXCEPTION 'invalid lender acknowledgement source' USING ERRCODE='23514'; END IF; ELSE SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=NEW.acknowledgement_id; IF TG_TABLE_NAME='loan_lender_acknowledgement_loans' THEN SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id; IF NOT (NEW.loan_id=ANY(a.loan_ids)) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR (c.confirmation_status<>'unconfirmed' AND NOT EXISTS( SELECT 1 FROM loan_lender_acknowledgement_loans old_scope JOIN loan_lender_acknowledgements old_ack ON old_ack.id=old_scope.acknowledgement_id WHERE old_scope.loan_id=c.id AND old_ack.invalidated_at IS NOT NULL)) OR NEW.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(NEW.loan_id) OR jsonb_array_length(NEW.snapshot_json->'originals')=0 OR EXISTS(SELECT 1 FROM loan_lender_acknowledgement_decisions WHERE acknowledgement_id=a.id) THEN RAISE EXCEPTION 'invalid lender acknowledgement scope' USING ERRCODE='23514'; END IF; ELSE -- Same lock order as the service and source maintenance. Never take the -- acknowledgement first and then wait for its source loan/lender. SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id FOR UPDATE; PERFORM id FROM company_loans WHERE id=ANY(a.loan_ids) ORDER BY id FOR UPDATE; SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=a.id FOR UPDATE; IF a.invalidated_at IS NOT NULL OR NEW.actor_id<>a.employee_id OR NEW.actor_id=a.proposed_by OR l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM a.employee_id OR l.identity_version<>a.identity_version OR l.account_version<>a.account_version OR NOT EXISTS(SELECT 1 FROM employees WHERE id=NEW.actor_id AND status='active' AND archived_at IS NULL) OR EXISTS(SELECT 1 FROM loan_lender_acknowledgement_loans s WHERE s.acknowledgement_id=a.id AND s.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(s.loan_id)) OR (SELECT count(*) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=a.id)<>cardinality(a.loan_ids) THEN RAISE EXCEPTION 'invalid lender acknowledgement decision' USING ERRCODE='23514'; END IF; END IF; END IF; RETURN NEW; END $$; CREATE TRIGGER lender_ack_header_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgements FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); CREATE TRIGGER lender_ack_loan_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); CREATE TRIGGER lender_ack_decision_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_decisions FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); CREATE FUNCTION check_loan_lender_ack_complete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.loan_ids IS DISTINCT FROM (SELECT array_agg(loan_id ORDER BY loan_id) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=NEW.id) THEN RAISE EXCEPTION 'incomplete lender acknowledgement snapshot' USING ERRCODE='23514'; END IF; RETURN NULL; END $$; CREATE CONSTRAINT TRIGGER lender_ack_complete AFTER INSERT ON loan_lender_acknowledgements DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_lender_ack_complete(); CREATE FUNCTION invalidate_loan_lender_ack() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE affected UUID[]; BEGIN IF TG_TABLE_NAME='company_loans' THEN IF ROW(OLD.lender_id,OLD.legal_entity_id,OLD.loan_no,OLD.origin_reference,OLD.currency_code, OLD.original_principal,OLD.opening_outstanding_principal,OLD.received_date,OLD.balance_as_of,OLD.historical_entry) IS NOT DISTINCT FROM ROW(NEW.lender_id,NEW.legal_entity_id,NEW.loan_no,NEW.origin_reference,NEW.currency_code, NEW.original_principal,NEW.opening_outstanding_principal,NEW.received_date,NEW.balance_as_of,NEW.historical_entry) THEN RETURN NULL; END IF; affected:=ARRAY[NEW.id]; ELSIF TG_TABLE_NAME='loan_lenders' THEN IF ROW(OLD.employee_id,OLD.identity_status,OLD.identity_version,OLD.account_version,OLD.archived_at, OLD.name,OLD.bank_account_encrypted,OLD.account_name_encrypted,OLD.bank_institution_id,OLD.bank_name) IS NOT DISTINCT FROM ROW(NEW.employee_id,NEW.identity_status,NEW.identity_version,NEW.account_version,NEW.archived_at, NEW.name,NEW.bank_account_encrypted,NEW.account_name_encrypted,NEW.bank_institution_id,NEW.bank_name) THEN RETURN NULL; END IF; UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,invalid_reason='lender_changed' WHERE lender_id=NEW.id AND invalidated_at IS NULL; RETURN NULL; ELSE IF TG_OP='INSERT' THEN IF NEW.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[NEW.loan_id]; ELSIF TG_OP='DELETE' THEN IF OLD.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id]; ELSE IF (OLD.kind<>'incoming' AND NEW.kind<>'incoming') OR OLD IS NOT DISTINCT FROM NEW THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id,NEW.loan_id]; END IF; END IF; UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP, invalid_reason=CASE WHEN TG_TABLE_NAME='company_loans' THEN 'loan_changed' ELSE 'original_changed' END WHERE invalidated_at IS NULL AND loan_ids && affected; RETURN NULL; END $$; CREATE TRIGGER lender_ack_loan_source AFTER UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack(); CREATE TRIGGER lender_ack_lender_source AFTER UPDATE ON loan_lenders FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack(); CREATE TRIGGER lender_ack_original_source AFTER INSERT OR UPDATE OR DELETE ON loan_evidences FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();