-- =====================================================================
-- Migration 0003 — Universal transaction ledger (roadmap Step 4).
--
-- One append-only table for every customer-facing transaction
-- (OMT send/receive, bill payment, recharge, goods sale, etc.).
-- Service-specific detail tables are added in 0004.
--
-- Design choices and the threats they kill:
--
--   * INSERT-only at the SQL level. UPDATE is allowed only by the
--     dedicated `void_transaction` function, and it can only flip the
--     status to 'voided' plus set void fields. Triggers enforce this even
--     against superuser app roles.        (vectors #1, #2, #11, #18)
--
--   * Sequential `reference_no` per shop, allocated by a Postgres
--     sequence inside a SECURITY DEFINER function — gaps are visible and
--     a daily report can flag missing numbers. (vector #1)
--
--   * Row hash chain: each row stores a sha256 of its own canonical
--     content + the previous row's hash for the same shop. Anchored
--     daily off-site, this detects silent edits even by an insider DBA.
--     (vector #24)
--
--   * `external_ref` (OMT code, recharge confirmation, etc.) is unique
--     per provider — blocks replay of an old receipt to a new customer.
--     (vector #19)
--
--   * Server-stamped `occurred_at`, `created_by`, and shift/shop/till
--     ids — cashier can not backdate or attribute to someone else.
--     (vectors #20, #25)
--
--   * Voids are bound to a 10-minute window (configurable) for cashier
--     self-service, and require a manager `void_approved_by` after that.
--     (vector #11)
--
--   * Cash movements (0002) get an FK to this ledger so every cash
--     in/out is traceable to a transaction or to an explicit non-sale
--     movement (drop, expense, swap...).
-- =====================================================================

-- =====================================================================
-- Enums and reference data
-- =====================================================================
do $$ begin
  create type app.txn_status as enum ('completed', 'voided');
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.payment_method as enum (
    'cash_usd', 'cash_lbp', 'whish', 'omt_wallet', 'card', 'bank_transfer'
  );
exception when duplicate_object then null; end $$;

-- Service catalog (seeded at the bottom of this file).
create table if not exists app.services (
  code        text primary key,
  name        text not null,
  category    text not null,
  is_active   boolean not null default true,
  created_at  timestamptz not null default now()
);

-- Per-shop receipt-number sequences ------------------------------------
create table if not exists app.shop_sequences (
  shop_id    uuid primary key references app.shops(id) on delete cascade,
  next_value bigint not null default 1
);

-- =====================================================================
-- The ledger
-- =====================================================================
create table if not exists app.transactions (
  id              uuid primary key default gen_random_uuid(),

  -- Routing
  shift_id        uuid not null references app.shifts(id) on delete restrict,
  shop_id         uuid not null references app.shops(id)  on delete restrict,
  till_id         uuid not null references app.tills(id)  on delete restrict,
  user_id         uuid not null references auth.users(id) on delete restrict,
  service_code    text not null references app.services(code),

  -- Identifiers
  occurred_at     timestamptz not null default now(),
  reference_no    bigint not null,                       -- per shop, sequential
  external_ref    text,                                  -- OMT code, recharge id...
  external_ref_provider text,                            -- 'OMT','ALFA','TOUCH','OGERO',...

  status          app.txn_status not null default 'completed',

  -- Money (dual-currency on the same row; either side may be 0)
  gross_usd       numeric(14,2) not null default 0 check (gross_usd >= 0),
  gross_lbp       numeric(18,0) not null default 0 check (gross_lbp >= 0),
  fee_usd         numeric(14,2) not null default 0 check (fee_usd  >= 0),
  fee_lbp         numeric(18,0) not null default 0 check (fee_lbp  >= 0),
  commission_usd  numeric(14,2) not null default 0 check (commission_usd >= 0),
  commission_lbp  numeric(18,0) not null default 0 check (commission_lbp >= 0),
  fx_rate_used    numeric(18,4),                         -- USD/LBP at moment of txn
  payment_method  app.payment_method not null,

  -- Counterparty (used by various services; child tables hold the rest)
  customer_id        uuid,    -- FK added in 0005 (KYC module)
  beneficiary_name   text,
  beneficiary_phone  text,
  msisdn             text,
  operator           text,
  product_code       text,
  voucher_serial     text,

  notes              text,
  receipt_url        text,

  -- Audit
  created_at         timestamptz not null default now(),
  created_by         uuid not null references auth.users(id),
  voided_at          timestamptz,
  voided_by          uuid references auth.users(id),
  void_reason        text,
  void_approved_by   uuid references auth.users(id),

  -- Hash chain (per shop)
  row_hash      bytea not null,
  prev_row_hash bytea,

  -- Constraints
  constraint txn_unique_per_shop_ref unique (shop_id, reference_no),
  constraint txn_unique_external_ref unique (external_ref_provider, external_ref),
  constraint txn_void_consistency check (
       (status = 'completed' and voided_at is null and voided_by is null and void_reason is null)
    or (status = 'voided'    and voided_at is not null and voided_by is not null and void_reason is not null)
  )
);

create index if not exists idx_txn_shop_time   on app.transactions(shop_id, occurred_at desc);
create index if not exists idx_txn_shift       on app.transactions(shift_id, occurred_at);
create index if not exists idx_txn_user_time   on app.transactions(user_id, occurred_at desc);
create index if not exists idx_txn_service     on app.transactions(service_code, occurred_at desc);
create index if not exists idx_txn_status      on app.transactions(status) where status = 'voided';
create index if not exists idx_txn_msisdn      on app.transactions(msisdn) where msisdn is not null;
create index if not exists idx_txn_external    on app.transactions(external_ref_provider, external_ref);

-- Now that transactions exists, attach the deferred FK from cash_movements.
alter table app.cash_movements
  drop constraint if exists cash_movements_ref_txn_fk;
alter table app.cash_movements
  add  constraint cash_movements_ref_txn_fk
       foreign key (ref_txn_id) references app.transactions(id) on delete restrict;

-- =====================================================================
-- Hash-chain helpers
-- =====================================================================
create or replace function app.txn_canonical_payload(t app.transactions)
returns text
language sql
immutable
as $$
  select jsonb_build_object(
    'id',                t.id,
    'shop_id',           t.shop_id,
    'till_id',           t.till_id,
    'shift_id',          t.shift_id,
    'user_id',           t.user_id,
    'service_code',      t.service_code,
    'occurred_at',       to_char(t.occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MSOF'),
    'reference_no',      t.reference_no,
    'external_ref',      t.external_ref,
    'external_ref_provider', t.external_ref_provider,
    'status',            t.status,
    'gross_usd',         t.gross_usd,
    'gross_lbp',         t.gross_lbp,
    'fee_usd',           t.fee_usd,
    'fee_lbp',           t.fee_lbp,
    'commission_usd',    t.commission_usd,
    'commission_lbp',    t.commission_lbp,
    'fx_rate_used',      t.fx_rate_used,
    'payment_method',    t.payment_method,
    'customer_id',       t.customer_id,
    'beneficiary_name',  t.beneficiary_name,
    'beneficiary_phone', t.beneficiary_phone,
    'msisdn',            t.msisdn,
    'operator',          t.operator,
    'product_code',      t.product_code,
    'voucher_serial',    t.voucher_serial,
    'notes',             t.notes,
    'receipt_url',       t.receipt_url,
    'created_by',        t.created_by,
    'voided_at',         t.voided_at,
    'voided_by',         t.voided_by,
    'void_reason',       t.void_reason,
    'void_approved_by',  t.void_approved_by
  )::text;
$$;

create or replace function app.txn_compute_hash(t app.transactions, prev bytea)
returns bytea
language sql
immutable
as $$
  select digest(coalesce(prev, '\x'::bytea) || convert_to(app.txn_canonical_payload(t), 'UTF8'), 'sha256');
$$;

-- =====================================================================
-- Triggers — block raw writes; allow only what we sanction
-- =====================================================================

-- Block direct UPDATE/DELETE except when our SECURITY DEFINER void
-- function turns on the session GUC.
create or replace function app.txn_guard_update_delete()
returns trigger language plpgsql as $$
begin
  if (tg_op = 'DELETE') then
    raise exception 'transactions cannot be deleted';
  end if;
  if current_setting('app.txn_internal', true) is distinct from 'on' then
    raise exception 'direct UPDATE on app.transactions is not allowed; use app.void_transaction';
  end if;
  -- Even via the void path, only the void/status fields may change.
  if (new.id <> old.id
   or new.shop_id <> old.shop_id
   or new.till_id <> old.till_id
   or new.shift_id <> old.shift_id
   or new.user_id <> old.user_id
   or new.service_code <> old.service_code
   or new.occurred_at <> old.occurred_at
   or new.reference_no <> old.reference_no
   or coalesce(new.external_ref,'')          <> coalesce(old.external_ref,'')
   or coalesce(new.external_ref_provider,'') <> coalesce(old.external_ref_provider,'')
   or new.gross_usd <> old.gross_usd
   or new.gross_lbp <> old.gross_lbp
   or new.fee_usd   <> old.fee_usd
   or new.fee_lbp   <> old.fee_lbp
   or new.commission_usd <> old.commission_usd
   or new.commission_lbp <> old.commission_lbp
   or coalesce(new.fx_rate_used, -1) <> coalesce(old.fx_rate_used, -1)
   or new.payment_method <> old.payment_method
   or new.created_by <> old.created_by
   or new.created_at <> old.created_at) then
    raise exception 'only status/void fields may change on a transaction';
  end if;
  return new;
end;
$$;

drop trigger if exists trg_txn_guard_update on app.transactions;
create trigger trg_txn_guard_update
  before update on app.transactions
  for each row execute function app.txn_guard_update_delete();

drop trigger if exists trg_txn_guard_delete on app.transactions;
create trigger trg_txn_guard_delete
  before delete on app.transactions
  for each row execute function app.txn_guard_update_delete();

-- Server stamping + hash chain on insert.
create or replace function app.txn_before_insert()
returns trigger
language plpgsql
as $$
declare
  v_prev_hash bytea;
  v_seq       bigint;
  v_shift     app.shifts%rowtype;
begin
  -- Caller identity / time are server-controlled.
  new.created_by  := auth.uid();
  new.created_at  := now();
  new.occurred_at := now();
  new.status      := 'completed';
  new.voided_at   := null;
  new.voided_by   := null;
  new.void_reason := null;
  new.void_approved_by := null;

  -- Shift must be open and owned by the caller; shop/till derived from it.
  select * into v_shift from app.shifts where id = new.shift_id;
  if v_shift.id is null then
    raise exception 'shift % not found', new.shift_id;
  end if;
  if v_shift.status <> 'open' then
    raise exception 'cannot post a transaction to a % shift', v_shift.status;
  end if;
  if v_shift.user_id <> auth.uid() then
    raise exception 'only the shift owner may post transactions to it';
  end if;
  new.shop_id := v_shift.shop_id;
  new.till_id := v_shift.till_id;
  new.user_id := v_shift.user_id;

  -- Allocate the shop's next reference number (advisory lock keeps it
  -- gap-free under concurrency).
  perform pg_advisory_xact_lock(hashtext('shop_seq:' || new.shop_id::text));
  insert into app.shop_sequences(shop_id, next_value)
    values (new.shop_id, 1)
    on conflict (shop_id) do nothing;
  update app.shop_sequences
    set next_value = next_value + 1
    where shop_id = new.shop_id
    returning next_value - 1 into v_seq;
  new.reference_no := v_seq;

  -- Compute hash linking to previous row in this shop.
  select row_hash into v_prev_hash
  from app.transactions
  where shop_id = new.shop_id
  order by reference_no desc
  limit 1;
  new.prev_row_hash := v_prev_hash;
  new.row_hash      := app.txn_compute_hash(new, v_prev_hash);

  return new;
end;
$$;

drop trigger if exists trg_txn_before_insert on app.transactions;
create trigger trg_txn_before_insert
  before insert on app.transactions
  for each row execute function app.txn_before_insert();

-- =====================================================================
-- Void
-- =====================================================================
-- Configurable self-service void window (minutes).
create table if not exists app.system_settings (
  key   text primary key,
  value text not null
);
insert into app.system_settings(key, value)
  values ('void_self_window_minutes', '10')
  on conflict (key) do nothing;

create or replace function app.void_transaction(
  p_txn_id      uuid,
  p_reason      text,
  p_approver_pin text default null   -- required for manager approval path
)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
  t      app.transactions%rowtype;
  s      app.shifts%rowtype;
  window_min int;
  needs_manager boolean;
begin
  select * into t from app.transactions where id = p_txn_id;
  if t.id is null then raise exception 'transaction not found'; end if;
  if t.status = 'voided' then raise exception 'transaction already voided'; end if;
  if p_reason is null or length(btrim(p_reason)) < 5 then
    raise exception 'a reason of at least 5 characters is required';
  end if;

  select * into s from app.shifts where id = t.shift_id;
  if s.status <> 'open' then
    raise exception 'cannot void a transaction whose shift is no longer open';
  end if;

  select coalesce(value::int, 10) into window_min
    from app.system_settings where key = 'void_self_window_minutes';

  needs_manager := (auth.uid() <> t.user_id)
                or (now() - t.created_at > make_interval(mins => window_min));

  if needs_manager then
    -- Caller must be a manager in this shop AND prove it with PIN.
    if not app.has_role_in_shop(t.shop_id, 'manager') then
      raise exception 'manager approval required to void this transaction';
    end if;
    if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
      raise exception 'manager PIN required and must be valid';
    end if;
  end if;

  -- Apply the void (only allowed via this function thanks to the guard).
  perform set_config('app.txn_internal', 'on', true);
  update app.transactions
    set status           = 'voided',
        voided_at        = now(),
        voided_by        = auth.uid(),
        void_reason      = p_reason,
        void_approved_by = case when needs_manager then auth.uid() else null end
  where id = p_txn_id;
  perform set_config('app.txn_internal', 'off', true);

  -- Recompute the row's hash so the chain reflects the new state.
  perform set_config('app.txn_internal', 'on', true);
  update app.transactions tt
    set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
    where id = p_txn_id;
  perform set_config('app.txn_internal', 'off', true);

  perform app.log_auth_event('txn_voided', t.shop_id, null,
    jsonb_build_object('txn_id', p_txn_id, 'manager_path', needs_manager));
end;
$$;

revoke all on function app.void_transaction(uuid, text, text) from public;
grant execute on function app.void_transaction(uuid, text, text) to authenticated;

-- =====================================================================
-- Daily integrity checks (callable by an owner cron)
-- =====================================================================
create or replace function app.verify_chain(p_shop uuid)
returns table (txn_id uuid, reference_no bigint, ok boolean)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare prev bytea;
        rec  app.transactions%rowtype;
begin
  if not app.has_role_in_shop(p_shop, 'owner')
     and not app.has_role_in_shop(p_shop, 'auditor') then
    raise exception 'not authorized';
  end if;
  prev := null;
  for rec in
    select * from app.transactions
    where shop_id = p_shop
    order by reference_no
  loop
    txn_id := rec.id;
    reference_no := rec.reference_no;
    ok := (rec.prev_row_hash is not distinct from prev)
      and (rec.row_hash = app.txn_compute_hash(rec, prev));
    prev := rec.row_hash;
    return next;
  end loop;
end;
$$;

revoke all on function app.verify_chain(uuid) from public;
grant execute on function app.verify_chain(uuid) to authenticated;

-- Reference-number gap detector
create or replace view app.v_reference_gaps as
select shop_id,
       reference_no + 1 as gap_starts_at,
       next_ref - 1     as gap_ends_at
from (
  select shop_id, reference_no,
         lead(reference_no) over (partition by shop_id order by reference_no) as next_ref
  from app.transactions
) s
where next_ref is not null and next_ref <> reference_no + 1;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.transactions  enable row level security;
alter table app.transactions  force  row level security;
alter table app.services      enable row level security;
alter table app.services      force  row level security;
alter table app.shop_sequences enable row level security;
alter table app.shop_sequences force  row level security;
alter table app.system_settings enable row level security;
alter table app.system_settings force  row level security;

-- Direct UPDATE/DELETE blocked by triggers, but also revoke at SQL level.
revoke update, delete on app.transactions    from authenticated;
revoke insert, update, delete on app.shop_sequences from authenticated;
revoke insert, update, delete on app.system_settings from authenticated;

drop policy if exists txn_select on app.transactions;
create policy txn_select on app.transactions
  for select to authenticated
  using (
    user_id = auth.uid()
    or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
  );

drop policy if exists txn_insert_in_open_shift on app.transactions;
create policy txn_insert_in_open_shift on app.transactions
  for insert to authenticated
  with check (
    exists (
      select 1 from app.shifts s
      where s.id = transactions.shift_id
        and s.status  = 'open'
        and s.user_id = auth.uid()
    )
  );

-- Services: readable by all authenticated users; only owners may modify
-- (via direct grants kept off, future migration will add a function).
drop policy if exists services_select on app.services;
create policy services_select on app.services
  for select to authenticated using (true);

-- Shop sequences and system settings: readable by owner/auditor.
drop policy if exists shop_seq_select on app.shop_sequences;
create policy shop_seq_select on app.shop_sequences
  for select to authenticated
  using (app.has_any_role_in_shop(shop_id, array['owner','auditor']::app.business_role[]));

drop policy if exists settings_select on app.system_settings;
create policy settings_select on app.system_settings
  for select to authenticated using (true);

grant select, insert on app.transactions  to authenticated;
grant select         on app.services       to authenticated;
grant select         on app.shop_sequences to authenticated;
grant select         on app.system_settings to authenticated;

-- =====================================================================
-- Seed services
-- =====================================================================
insert into app.services(code, name, category) values
  ('OMT_SEND',         'OMT Send',           'transfer'),
  ('OMT_RECEIVE',      'OMT Receive/Payout', 'transfer'),
  ('OMT_BILL',         'OMT Bill Payment',   'bill'),
  ('WU_SEND',          'Western Union Send', 'transfer'),
  ('WU_RECEIVE',       'Western Union Pay',  'transfer'),
  ('ALFA_RECHARGE',    'Alfa Recharge',      'recharge'),
  ('TOUCH_RECHARGE',   'touch Recharge',     'recharge'),
  ('OGERO_RECHARGE',   'Ogero Recharge',     'recharge'),
  ('INTERNET_RECHARGE','Internet Recharge',  'recharge'),
  ('SIM_SALE',         'SIM Sale',           'goods'),
  ('PHONE_SALE',       'Phone Sale',         'goods'),
  ('ACCESSORY_SALE',   'Accessory Sale',     'goods'),
  ('REPAIR',           'Repair Service',     'service')
on conflict (code) do nothing;

-- End migration 0003 ----------------------------------------------------
