Files
OMT-SM/supabase/migrations/0004_service_details.sql
T

389 lines
17 KiB
PL/PgSQL

-- =====================================================================
-- Migration 0004 — Service-specific detail tables (roadmap Step 5).
--
-- Each child row is 1-to-1 with a row in app.transactions and is
-- mandatory for its service. A check trigger blocks completing a txn of
-- a given service without the matching detail row.
--
-- Threat-model rows addressed: 1, 3, 5, 16, 19, 21.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Common helpers
-- ---------------------------------------------------------------------
do $$ begin
create type app.id_doc_type as enum (
'lebanese_id', 'passport', 'residence_permit', 'driver_license', 'other'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.transfer_direction as enum ('domestic', 'international');
exception when duplicate_object then null; end $$;
-- A small helper used by all child triggers: the parent txn must exist,
-- be 'completed' (we attach detail at insert time only), and match the
-- expected service_code.
create or replace function app._require_txn_service(p_txn uuid, p_service text)
returns void
language plpgsql
stable
as $$
declare svc text; st app.txn_status;
begin
select service_code, status into svc, st
from app.transactions where id = p_txn;
if svc is null then raise exception 'transaction % not found', p_txn; end if;
if svc <> p_service then
raise exception 'detail mismatch: txn service is % but detail row is for %',
svc, p_service;
end if;
if st <> 'completed' then
raise exception 'cannot attach detail to a % transaction', st;
end if;
end;
$$;
-- =====================================================================
-- OMT — Send
-- =====================================================================
create table if not exists app.omt_send_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
direction app.transfer_direction not null,
sender_full_name text not null,
sender_id_type app.id_doc_type not null,
sender_id_number text not null,
sender_phone text not null,
sender_dob date,
sender_nationality text,
beneficiary_full_name text not null,
beneficiary_phone text,
destination_country text, -- ISO-3166 alpha-2 expected for international
purpose_code text not null, -- 'family_support','salary','goods','services',...
purpose_note text,
kyc_doc_url text, -- ID photo / declaration
created_at timestamptz not null default now(),
constraint omt_send_intl_country_required check (
direction = 'domestic' or destination_country is not null
),
constraint omt_send_id_format check (length(btrim(sender_id_number)) >= 4)
);
-- =====================================================================
-- OMT — Receive / Payout
-- =====================================================================
create table if not exists app.omt_receive_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
payout_code text not null, -- the customer-presented code
beneficiary_full_name text not null,
beneficiary_id_type app.id_doc_type not null,
beneficiary_id_number text not null,
beneficiary_phone text,
origin_country text,
kyc_doc_url text,
created_at timestamptz not null default now(),
constraint omt_recv_id_format check (length(btrim(beneficiary_id_number)) >= 4),
constraint omt_recv_code_format check (length(btrim(payout_code)) >= 6)
);
-- =====================================================================
-- Bill payment (EDL, water, internet bills, gov fees, etc.)
-- =====================================================================
create table if not exists app.bill_payment_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
biller_code text not null, -- 'EDL','OGERO','MOF','NSSF',...
account_number text not null,
period text, -- '2026-04', invoice id, etc.
customer_name text,
created_at timestamptz not null default now(),
constraint bill_account_format check (length(btrim(account_number)) >= 3)
);
-- =====================================================================
-- Recharge (Alfa / touch / Ogero / Internet)
-- Either a voucher_serial (physical scratch card) OR an
-- e_recharge_provider_ref (provider confirmation id) is mandatory.
-- =====================================================================
create table if not exists app.recharge_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
operator text not null, -- 'ALFA','TOUCH','OGERO','IDM','CYBERIA','TERRANET'
msisdn text not null, -- subscriber number being recharged
product_code text not null, -- 'U-CARD-22USD','MAGIC-11USD','DATA-5GB',...
voucher_serial text, -- if scratch card
e_recharge_provider_ref text, -- if e-recharge
unit_face_value_usd numeric(14,2),
unit_cost_usd numeric(14,2), -- cost to shop (margin = price - cost)
created_at timestamptz not null default now(),
constraint recharge_msisdn_format check (msisdn ~ '^\+?\d{6,15}$'),
constraint recharge_must_have_evidence check (
(voucher_serial is not null) or (e_recharge_provider_ref is not null)
)
);
-- A given voucher serial may only ever be sold once across the whole
-- system (vector #4: skim a card, claim "lost").
create unique index if not exists uq_recharge_voucher_serial
on app.recharge_details(voucher_serial)
where voucher_serial is not null;
-- An e-recharge provider reference is unique per operator.
create unique index if not exists uq_recharge_provider_ref
on app.recharge_details(operator, e_recharge_provider_ref)
where e_recharge_provider_ref is not null;
create index if not exists idx_recharge_msisdn on app.recharge_details(msisdn);
create index if not exists idx_recharge_operator on app.recharge_details(operator);
-- =====================================================================
-- Goods sale (SIM / phone / accessory) and repair
-- Real inventory FK comes in 0006; for now we capture sku + qty.
-- =====================================================================
create table if not exists app.goods_sale_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
sku text not null,
qty integer not null check (qty > 0),
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
unit_price_usd numeric(14,2) not null check (unit_price_usd >= 0),
serial_number text, -- IMEI for phones
created_at timestamptz not null default now()
);
create table if not exists app.repair_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
device_type text not null,
device_imei text,
issue_summary text not null,
warranty_days integer not null default 0 check (warranty_days >= 0),
created_at timestamptz not null default now()
);
-- =====================================================================
-- Triggers — service consistency + append-only on details
-- =====================================================================
create or replace function app._detail_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction detail rows are append-only'; end;
$$;
-- per-table: stamp + service-code check + immutability
create or replace function app.omt_send_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_SEND'); return new; end;
$$;
drop trigger if exists trg_omt_send_check on app.omt_send_details;
create trigger trg_omt_send_check before insert on app.omt_send_details
for each row execute function app.omt_send_check();
drop trigger if exists trg_omt_send_freeze on app.omt_send_details;
create trigger trg_omt_send_freeze before update or delete on app.omt_send_details
for each row execute function app._detail_no_update_delete();
create or replace function app.omt_recv_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_RECEIVE'); return new; end;
$$;
drop trigger if exists trg_omt_recv_check on app.omt_receive_details;
create trigger trg_omt_recv_check before insert on app.omt_receive_details
for each row execute function app.omt_recv_check();
drop trigger if exists trg_omt_recv_freeze on app.omt_receive_details;
create trigger trg_omt_recv_freeze before update or delete on app.omt_receive_details
for each row execute function app._detail_no_update_delete();
create or replace function app.bill_pay_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_BILL'); return new; end;
$$;
drop trigger if exists trg_bill_pay_check on app.bill_payment_details;
create trigger trg_bill_pay_check before insert on app.bill_payment_details
for each row execute function app.bill_pay_check();
drop trigger if exists trg_bill_pay_freeze on app.bill_payment_details;
create trigger trg_bill_pay_freeze before update or delete on app.bill_payment_details
for each row execute function app._detail_no_update_delete();
-- Recharge: any of the four recharge service codes is acceptable.
create or replace function app.recharge_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('ALFA_RECHARGE','TOUCH_RECHARGE','OGERO_RECHARGE','INTERNET_RECHARGE') then
raise exception 'recharge_details only valid for recharge services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_recharge_check on app.recharge_details;
create trigger trg_recharge_check before insert on app.recharge_details
for each row execute function app.recharge_check();
drop trigger if exists trg_recharge_freeze on app.recharge_details;
create trigger trg_recharge_freeze before update or delete on app.recharge_details
for each row execute function app._detail_no_update_delete();
create or replace function app.goods_sale_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('SIM_SALE','PHONE_SALE','ACCESSORY_SALE') then
raise exception 'goods_sale_details only valid for goods services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_goods_sale_check on app.goods_sale_details;
create trigger trg_goods_sale_check before insert on app.goods_sale_details
for each row execute function app.goods_sale_check();
drop trigger if exists trg_goods_sale_freeze on app.goods_sale_details;
create trigger trg_goods_sale_freeze before update or delete on app.goods_sale_details
for each row execute function app._detail_no_update_delete();
create or replace function app.repair_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'REPAIR'); return new; end;
$$;
drop trigger if exists trg_repair_check on app.repair_details;
create trigger trg_repair_check before insert on app.repair_details
for each row execute function app.repair_check();
drop trigger if exists trg_repair_freeze on app.repair_details;
create trigger trg_repair_freeze before update or delete on app.repair_details
for each row execute function app._detail_no_update_delete();
-- =====================================================================
-- Cross-row check: a completed transaction must have its matching
-- detail row. Implemented as a deferred constraint trigger that fires
-- at COMMIT time on app.transactions, so client code can do
-- BEGIN; INSERT txn; INSERT detail; COMMIT;
-- =====================================================================
create or replace function app.txn_require_detail()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
if new.status <> 'completed' then return null; end if;
case new.service_code
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
else ok := true; -- unknown / future services: allow until a child is added
end case;
if not ok then
raise exception 'transaction % (service %) is missing its detail row',
new.id, new.service_code;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_require_detail on app.transactions;
create constraint trigger trg_txn_require_detail
after insert on app.transactions
deferrable initially deferred
for each row execute function app.txn_require_detail();
-- =====================================================================
-- RLS — visibility follows the parent transaction.
-- =====================================================================
alter table app.omt_send_details enable row level security;
alter table app.omt_receive_details enable row level security;
alter table app.bill_payment_details enable row level security;
alter table app.recharge_details enable row level security;
alter table app.goods_sale_details enable row level security;
alter table app.repair_details enable row level security;
alter table app.omt_send_details force row level security;
alter table app.omt_receive_details force row level security;
alter table app.bill_payment_details force row level security;
alter table app.recharge_details force row level security;
alter table app.goods_sale_details force row level security;
alter table app.repair_details force row level security;
revoke update, delete on
app.omt_send_details, app.omt_receive_details, app.bill_payment_details,
app.recharge_details, app.goods_sale_details, app.repair_details
from authenticated;
-- Helper: visibility predicate based on parent txn.
create or replace function app._can_see_txn(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
where t.id = p_txn
and (
t.user_id = auth.uid()
or app.has_any_role_in_shop(t.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
$$;
revoke all on function app._can_see_txn(uuid) from public;
grant execute on function app._can_see_txn(uuid) to authenticated;
-- Insert allowed if the user owns the parent txn's open shift.
create or replace function app._can_write_detail(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
join app.shifts s on s.id = t.shift_id
where t.id = p_txn
and t.user_id = auth.uid()
and s.status = 'open'
);
$$;
revoke all on function app._can_write_detail(uuid) from public;
grant execute on function app._can_write_detail(uuid) to authenticated;
-- Apply identical select/insert policies to all six child tables.
do $$
declare tbl text;
begin
foreach tbl in array array[
'omt_send_details','omt_receive_details','bill_payment_details',
'recharge_details','goods_sale_details','repair_details'
] loop
execute format('drop policy if exists %I_select on app.%I;', tbl, tbl);
execute format($p$
create policy %I_select on app.%I
for select to authenticated
using (app._can_see_txn(txn_id));
$p$, tbl, tbl);
execute format('drop policy if exists %I_insert on app.%I;', tbl, tbl);
execute format($p$
create policy %I_insert on app.%I
for insert to authenticated
with check (app._can_write_detail(txn_id));
$p$, tbl, tbl);
execute format('grant select, insert on app.%I to authenticated;', tbl);
end loop;
end $$;
-- End migration 0004 ----------------------------------------------------