-- ===================================================================== -- 0024_idempotency_and_self_deal.sql -- -- Two related fraud vectors not yet closed: -- -- (A) Idempotency / replay. Today the cashier can post the same OMT -- payout code twice in the same shift and pocket the difference, -- or post the same WU MTCN twice and let the second one fail to -- reconcile silently. Nothing on the server enforces uniqueness -- of `(shop_id, external_ref_provider, external_ref)` for active -- money-transfer transactions. -- -- (B) Self-deal. A cashier processing transfers on their own KYC ID -- (or as the named beneficiary of a payout, or as the sender of -- a high-value send to themselves) is the classic skim pattern -- across all Lebanese MFS shops. The DB has all the data — the -- cashier's user_profiles row, plus sender_id_number / -- beneficiary_id_number on the detail row — but never compares -- them. -- -- This migration: -- * adds nullable `id_type` / `id_number` / `phone_kyc` columns to -- `app.user_profiles` (the cashier's own KYC), -- * unique index on (shop_id, external_ref_provider, external_ref) -- covering only completed (or pending) money-transfer service -- codes, -- * deferred constraint trigger that rejects an OMT/WU/Whish/bill -- txn whose sender or beneficiary ID matches the cashier's own -- KYC, unless an `app.system_settings` flag explicitly allows it -- AND the txn is approved by a manager. -- ===================================================================== set search_path = app, public; -- --------------------------------------------------------------------- -- (A) idempotent external_ref -- --------------------------------------------------------------------- -- A *partial unique* index limited to: -- * completed or pending status (voided rows can re-use a code if -- the original was void-reversed, which is desired), -- * money-transfer / bill service codes (recharges and goods sales -- don't carry meaningful external_ref uniqueness). create unique index if not exists ux_txn_external_ref_active on app.transactions (shop_id, external_ref_provider, external_ref) where external_ref is not null and external_ref_provider is not null and status <> 'voided' and service_code in ( 'OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE', 'WHISH_SEND','OMT_BILL','EDL_BILL' ); -- --------------------------------------------------------------------- -- (B) self-deal: extend user_profiles with cashier KYC -- --------------------------------------------------------------------- alter table app.user_profiles add column if not exists id_type app.id_doc_type, add column if not exists id_number text, add column if not exists phone_kyc text; create index if not exists idx_user_profiles_kyc_id on app.user_profiles(id_type, id_number) where id_number is not null; -- Allow a manager to bypass self-deal blocking for a specific txn by -- setting this knob; default is to block. insert into app.system_settings(key, value) values ('self_deal_block_enabled', 'true') on conflict (key) do nothing; -- --------------------------------------------------------------------- -- Helper: does an ID belong to the cashier who created the txn? -- --------------------------------------------------------------------- create or replace function app._is_cashier_self( p_user_id uuid, p_id_type app.id_doc_type, p_id_number text, p_phone text ) returns boolean language sql stable security definer set search_path = app, public as $$ select exists ( select 1 from app.user_profiles up where up.user_id = p_user_id and ( (p_id_number is not null and up.id_number is not null and up.id_type = p_id_type and lower(btrim(up.id_number)) = lower(btrim(p_id_number))) or (p_phone is not null and up.phone_kyc is not null and regexp_replace(up.phone_kyc, '\D', '', 'g') = regexp_replace(p_phone, '\D', '', 'g')) ) ); $$; revoke all on function app._is_cashier_self(uuid, app.id_doc_type, text, text) from public; -- --------------------------------------------------------------------- -- Constraint trigger: fired AFTER INSERT on the detail rows that carry -- counter-party identity. Each branch checks the txn owner against -- the recorded sender / beneficiary KYC. -- --------------------------------------------------------------------- create or replace function app._omt_send_self_deal_check() returns trigger language plpgsql security definer set search_path = app, public as $$ declare t app.transactions%rowtype; block boolean; begin select coalesce(value::boolean, true) into block from app.system_settings where key = 'self_deal_block_enabled'; if not block then return null; end if; select * into t from app.transactions where id = new.txn_id; if t.id is null then return null; end if; if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone) then raise exception 'self-deal blocked: cashier (% ) is the SENDER on txn % — manager must process this transfer', t.user_id, new.txn_id; end if; -- A cashier sending to themselves as beneficiary is also self-deal. -- We only have name+phone for the beneficiary on send rows, so match -- on phone (most reliable) when present. if new.beneficiary_phone is not null and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone) then raise exception 'self-deal blocked: cashier is the BENEFICIARY phone on txn %', new.txn_id; end if; return null; end; $$; drop trigger if exists trg_omt_send_self_deal on app.omt_send_details; create constraint trigger trg_omt_send_self_deal after insert on app.omt_send_details deferrable initially deferred for each row execute function app._omt_send_self_deal_check(); create or replace function app._omt_receive_self_deal_check() returns trigger language plpgsql security definer set search_path = app, public as $$ declare t app.transactions%rowtype; block boolean; begin select coalesce(value::boolean, true) into block from app.system_settings where key = 'self_deal_block_enabled'; if not block then return null; end if; select * into t from app.transactions where id = new.txn_id; if t.id is null then return null; end if; if app._is_cashier_self(t.user_id, new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone) then raise exception 'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn % — manager must process', new.txn_id; end if; return null; end; $$; drop trigger if exists trg_omt_receive_self_deal on app.omt_receive_details; create constraint trigger trg_omt_receive_self_deal after insert on app.omt_receive_details deferrable initially deferred for each row execute function app._omt_receive_self_deal_check(); -- --------------------------------------------------------------------- -- Manager-only knob: temporarily allow a single self-deal transfer -- (e.g. owner sending themselves their own salary). Auto-resets after -- one INSERT via a session GUC. -- --------------------------------------------------------------------- create or replace function app.manager_allow_next_self_deal( p_manager_pin text, p_shop uuid ) returns void language plpgsql security definer set search_path = app, public as $$ begin if not app.has_role_in_shop(p_shop, 'manager') and not app.has_role_in_shop(p_shop, 'owner') then raise exception 'manager or owner role required'; end if; if not app.verify_my_pin(p_manager_pin) then raise exception 'invalid manager PIN'; end if; perform set_config('app.self_deal_override', 'on', true); -- session GUC perform app.log_auth_event('self_deal_override_granted', p_shop, null, '{}'::jsonb); end; $$; revoke all on function app.manager_allow_next_self_deal(text, uuid) from public; grant execute on function app.manager_allow_next_self_deal(text, uuid) to authenticated; -- Wire the override into the self-deal checkers. create or replace function app._self_deal_overridden() returns boolean language sql stable as $$ select coalesce(current_setting('app.self_deal_override', true), 'off') = 'on'; $$; create or replace function app._omt_send_self_deal_check() returns trigger language plpgsql security definer set search_path = app, public as $$ declare t app.transactions%rowtype; block boolean; begin if app._self_deal_overridden() then perform set_config('app.self_deal_override', 'off', true); return null; end if; select coalesce(value::boolean, true) into block from app.system_settings where key = 'self_deal_block_enabled'; if not block then return null; end if; select * into t from app.transactions where id = new.txn_id; if t.id is null then return null; end if; if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone) then raise exception 'self-deal blocked: cashier is the SENDER on txn %', new.txn_id; end if; if new.beneficiary_phone is not null and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone) then raise exception 'self-deal blocked: cashier is the BENEFICIARY phone on txn %', new.txn_id; end if; return null; end; $$; create or replace function app._omt_receive_self_deal_check() returns trigger language plpgsql security definer set search_path = app, public as $$ declare t app.transactions%rowtype; block boolean; begin if app._self_deal_overridden() then perform set_config('app.self_deal_override', 'off', true); return null; end if; select coalesce(value::boolean, true) into block from app.system_settings where key = 'self_deal_block_enabled'; if not block then return null; end if; select * into t from app.transactions where id = new.txn_id; if t.id is null then return null; end if; if app._is_cashier_self(t.user_id, new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone) then raise exception 'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn %', new.txn_id; end if; return null; end; $$; -- --------------------------------------------------------------------- -- Convenience RPC for the manager UI to set/update a cashier's KYC. -- --------------------------------------------------------------------- create or replace function app.set_user_kyc( p_user_id uuid, p_shop uuid, p_id_type app.id_doc_type, p_id_number text, p_phone_kyc text ) returns void language plpgsql security definer set search_path = app, public as $$ begin if not app.has_role_in_shop(p_shop, 'manager') and not app.has_role_in_shop(p_shop, 'owner') then raise exception 'manager or owner role required'; end if; if p_id_number is null or btrim(p_id_number) = '' then raise exception 'id_number required'; end if; -- the user must actually be assigned to this shop if not exists( select 1 from app.user_shop_assignments where user_id = p_user_id and shop_id = p_shop ) then raise exception 'user is not assigned to that shop'; end if; update app.user_profiles set id_type = p_id_type, id_number = btrim(p_id_number), phone_kyc = nullif(btrim(p_phone_kyc),'') where user_id = p_user_id; perform app.log_auth_event('user_kyc_updated', p_shop, null, jsonb_build_object('user_id', p_user_id, 'id_type', p_id_type)); end; $$; revoke all on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) from public; grant execute on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) to authenticated;