-- =====================================================================
-- 0021_fee_schedule.sql
--
-- Today the cashier types fee_usd / fee_lbp / commission_usd / commission_lbp
-- by hand on every OMT_SEND, OMT_RECEIVE, WU_*, WHISH_SEND, EDL_BILL,
-- recharge and goods sale. There is no server-side anchor for what the
-- fee is *supposed* to be, which means a cashier can:
--
--   * pocket part of the customer's fee by recording a smaller fee
--     than they collected,
--   * record a larger fee than the official sheet to siphon shop
--     commission, then refund the excess to themselves later.
--
-- This migration adds an opt-in per-shop fee schedule:
--
--   app.fee_schedule(shop_id, service_code, currency,
--                    min_amount, max_amount,
--                    fee_fixed, fee_pct,
--                    commission_fixed, commission_pct,
--                    tolerance)
--
-- and a deferred constraint trigger that, *only when at least one row
-- exists for the shop+service+currency*, validates the fee/commission
-- on the transaction against the bracket the gross falls into. Shops
-- that don't seed the table keep working exactly as before.
-- =====================================================================

set search_path = app, public;

create table if not exists app.fee_schedule (
  id                 uuid primary key default gen_random_uuid(),
  shop_id            uuid not null references app.shops(id) on delete cascade,
  service_code       text not null references app.services(code),
  currency           app.currency_code not null,
  -- Inclusive lower bound, exclusive upper bound (use a very large
  -- max_amount for the "and above" bracket).
  min_amount         numeric(18,2) not null check (min_amount >= 0),
  max_amount         numeric(18,2) not null,
  fee_fixed          numeric(18,2) not null default 0 check (fee_fixed >= 0),
  fee_pct            numeric(7,4)  not null default 0 check (fee_pct >= 0 and fee_pct <= 100),
  commission_fixed   numeric(18,2) not null default 0 check (commission_fixed >= 0),
  commission_pct     numeric(7,4)  not null default 0 check (commission_pct >= 0 and commission_pct <= 100),
  -- Allowed absolute tolerance between scheduled and recorded fee. Set
  -- non-zero for services priced in LBP rounded to nearest 1000.
  tolerance          numeric(18,2) not null default 0 check (tolerance >= 0),
  effective_from     timestamptz not null default now(),
  effective_to       timestamptz,
  created_at         timestamptz not null default now(),
  created_by         uuid not null references auth.users(id) default auth.uid(),
  check (max_amount > min_amount),
  check (effective_to is null or effective_to > effective_from)
);

create index if not exists idx_fee_schedule_lookup
  on app.fee_schedule(shop_id, service_code, currency, effective_from desc);

alter table app.fee_schedule enable row level security;
alter table app.fee_schedule force  row level security;

-- Owners/managers of the shop can read and edit. Cashiers can read.
drop policy if exists fee_schedule_select on app.fee_schedule;
create policy fee_schedule_select on app.fee_schedule
  for select using (
    app.has_any_role_in_shop(shop_id,
      array['cashier','manager','owner']::app.business_role[])
  );

drop policy if exists fee_schedule_write on app.fee_schedule;
create policy fee_schedule_write on app.fee_schedule
  for all using (
    app.has_any_role_in_shop(shop_id,
      array['manager','owner']::app.business_role[])
  ) with check (
    app.has_any_role_in_shop(shop_id,
      array['manager','owner']::app.business_role[])
  );

-- ---------------------------------------------------------------------
-- Append-only on history: once published, a row's bracket cannot be
-- mutated; managers must close it (set effective_to) and insert a new
-- one. This preserves a clean audit trail of what fees were in force.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_immutable()
returns trigger language plpgsql as $$
begin
  if tg_op = 'DELETE' then
    raise exception 'fee_schedule rows are append-only; close them with effective_to';
  end if;

  -- Only effective_to may move forward (close a bracket). Everything
  -- else must stay put.
  if (old.shop_id, old.service_code, old.currency, old.min_amount,
      old.max_amount, old.fee_fixed, old.fee_pct,
      old.commission_fixed, old.commission_pct, old.tolerance,
      old.effective_from)
   is distinct from
     (new.shop_id, new.service_code, new.currency, new.min_amount,
      new.max_amount, new.fee_fixed, new.fee_pct,
      new.commission_fixed, new.commission_pct, new.tolerance,
      new.effective_from)
  then
    raise exception 'fee_schedule columns are immutable; close the row and insert a new one';
  end if;

  if old.effective_to is not null then
    raise exception 'fee_schedule row already closed';
  end if;
  if new.effective_to is null or new.effective_to <= now() - interval '1 minute' then
    raise exception 'effective_to must be set to a current/future timestamp to close a bracket';
  end if;
  return new;
end;
$$;
drop trigger if exists trg_fee_schedule_immutable on app.fee_schedule;
create trigger trg_fee_schedule_immutable
  before update or delete on app.fee_schedule
  for each row execute function app._fee_schedule_immutable();

-- ---------------------------------------------------------------------
-- Lookup helper: returns the active bracket for a (shop, service,
-- currency, gross). Returns NULL if no schedule applies.
-- ---------------------------------------------------------------------
create or replace function app.compute_scheduled_fee(
  p_shop      uuid,
  p_service   text,
  p_currency  app.currency_code,
  p_gross     numeric
) returns table (
  expected_fee        numeric,
  expected_commission numeric,
  tolerance           numeric,
  bracket_id          uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
  select
    coalesce(fs.fee_fixed,0)        + coalesce(fs.fee_pct,0)        / 100.0 * p_gross,
    coalesce(fs.commission_fixed,0) + coalesce(fs.commission_pct,0) / 100.0 * p_gross,
    fs.tolerance,
    fs.id
  from app.fee_schedule fs
  where fs.shop_id      = p_shop
    and fs.service_code = p_service
    and fs.currency     = p_currency
    and fs.min_amount  <= p_gross
    and fs.max_amount  >  p_gross
    and fs.effective_from <= now()
    and (fs.effective_to is null or fs.effective_to > now())
  order by fs.effective_from desc
  limit 1;
$$;

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

-- ---------------------------------------------------------------------
-- Constraint trigger: validates fee/commission against the schedule
-- when a matching bracket exists. Runs on INSERT (transactions are
-- append-only). Fired DEFERRED so the txn row is fully populated before
-- we look it up.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
  rec_usd record;
  rec_lbp record;
  diff numeric;
begin
  -- Only validate completed money transactions; refunds, voids and
  -- non-monetary services bypass.
  if new.status <> 'completed' then return null; end if;
  if new.service_code in ('REFUND','OPENING_FLOAT','SAFE_DROP','BANK_DEPOSIT')
  then return null; end if;

  if coalesce(new.gross_usd, 0) > 0 then
    select * into rec_usd
      from app.compute_scheduled_fee(new.shop_id, new.service_code, 'USD'::app.currency_code, new.gross_usd);

    if rec_usd.bracket_id is not null then
      diff := abs(coalesce(new.fee_usd,0) - rec_usd.expected_fee);
      if diff > rec_usd.tolerance then
        raise exception
          'fee_usd % deviates from schedule % (tolerance %, bracket %)',
          new.fee_usd, rec_usd.expected_fee, rec_usd.tolerance, rec_usd.bracket_id;
      end if;
      diff := abs(coalesce(new.commission_usd,0) - rec_usd.expected_commission);
      if diff > rec_usd.tolerance then
        raise exception
          'commission_usd % deviates from schedule % (tolerance %, bracket %)',
          new.commission_usd, rec_usd.expected_commission, rec_usd.tolerance, rec_usd.bracket_id;
      end if;
    end if;
  end if;

  if coalesce(new.gross_lbp, 0) > 0 then
    select * into rec_lbp
      from app.compute_scheduled_fee(new.shop_id, new.service_code, 'LBP'::app.currency_code, new.gross_lbp);

    if rec_lbp.bracket_id is not null then
      diff := abs(coalesce(new.fee_lbp,0) - rec_lbp.expected_fee);
      if diff > rec_lbp.tolerance then
        raise exception
          'fee_lbp % deviates from schedule % (tolerance %, bracket %)',
          new.fee_lbp, rec_lbp.expected_fee, rec_lbp.tolerance, rec_lbp.bracket_id;
      end if;
      diff := abs(coalesce(new.commission_lbp,0) - rec_lbp.expected_commission);
      if diff > rec_lbp.tolerance then
        raise exception
          'commission_lbp % deviates from schedule % (tolerance %, bracket %)',
          new.commission_lbp, rec_lbp.expected_commission, rec_lbp.tolerance, rec_lbp.bracket_id;
      end if;
    end if;
  end if;

  return null;
end;
$$;

drop trigger if exists trg_fee_schedule_check on app.transactions;
create constraint trigger trg_fee_schedule_check
  after insert on app.transactions
  deferrable initially deferred
  for each row execute function app._fee_schedule_check();
