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