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