Files
OMT-SM/supabase/migrations/0024_idempotency_and_self_deal.sql
T

332 lines
12 KiB
PL/PgSQL

-- =====================================================================
-- 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;