-- =====================================================================
-- 0023_fx_rates_and_swap.sql
--
-- Two related holes around foreign exchange:
--
--  (1) The cashier types an arbitrary `fx_rate_used` on every USD/LBP
--      transaction. Nothing on the server compares it to the daily
--      posted rate. A cashier can rate a $100 sale at 1 USD = 90,000 LBP
--      while the till uses 1 USD = 89,500 LBP and pocket the spread.
--
--  (2) `cash_movement_type` has 'fx_swap_in' and 'fx_swap_out' but no
--      function posts them as a matched pair. A cashier swapping $100
--      out of the till for 8.95M LBP today does it manually with two
--      uncoupled cash_movements rows; the sign-guard added in 0019
--      catches gross sign mistakes but not amount mismatches.
--
-- This migration:
--  * adds `app.fx_rates(shop_id, effective_from, usd_to_lbp_rate,
--    tolerance_pct)` — append-only history of the shop's posted rate.
--  * adds `app.compute_fx_window(shop, effective)` returning the
--    accepted band [low, high] for the currently-active rate.
--  * adds a constraint trigger on `app.transactions` that, only when
--    a rate is published for the shop, enforces fx_rate_used falls
--    within the band whenever both gross_usd and gross_lbp are non-zero
--    (genuine cross-currency txn) OR for explicit FX swaps.
--  * adds `app.record_fx_swap(shop, till, p_usd_amount, p_lbp_amount,
--    p_fx_rate)` which posts both legs atomically and refuses the call
--    unless |p_usd_amount * fx_rate - p_lbp_amount| <= 1 LBP.
--
-- Shops that don't seed `fx_rates` keep working unchanged.
-- =====================================================================

set search_path = app, public;

create table if not exists app.fx_rates (
  id              uuid primary key default gen_random_uuid(),
  shop_id         uuid not null references app.shops(id) on delete cascade,
  effective_from  timestamptz not null default now(),
  effective_to    timestamptz,
  -- Number of LBP per 1 USD (e.g. 89500).
  usd_to_lbp_rate numeric(14,2) not null check (usd_to_lbp_rate > 0),
  -- Allowed deviation either side of the posted rate, as a percent
  -- (e.g. 1.0 = ±1%). Defaults to 0.5%.
  tolerance_pct   numeric(6,3) not null default 0.5
                  check (tolerance_pct >= 0 and tolerance_pct <= 25),
  note            text,
  created_at      timestamptz not null default now(),
  created_by      uuid not null references auth.users(id) default auth.uid(),
  check (effective_to is null or effective_to > effective_from)
);

create index if not exists idx_fx_rates_lookup
  on app.fx_rates(shop_id, effective_from desc);

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

drop policy if exists fx_rates_select on app.fx_rates;
create policy fx_rates_select on app.fx_rates
  for select using (
    app.has_any_role_in_shop(shop_id,
      array['cashier','manager','owner']::app.business_role[])
  );

drop policy if exists fx_rates_write on app.fx_rates;
create policy fx_rates_write on app.fx_rates
  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: only effective_to may be moved forward. Same
-- pattern as fee_schedule in 0021.
create or replace function app._fx_rates_immutable()
returns trigger language plpgsql as $$
begin
  if tg_op = 'DELETE' then
    raise exception 'fx_rates is append-only';
  end if;
  if (old.shop_id, old.usd_to_lbp_rate, old.tolerance_pct, old.effective_from)
   is distinct from
     (new.shop_id, new.usd_to_lbp_rate, new.tolerance_pct, new.effective_from)
  then
    raise exception 'fx_rates columns are immutable; close the row and insert a new one';
  end if;
  if old.effective_to is not null then
    raise exception 'fx_rates row already closed';
  end if;
  if new.effective_to is null then
    raise exception 'effective_to must be set to close an fx_rates row';
  end if;
  return new;
end;
$$;
drop trigger if exists trg_fx_rates_immutable on app.fx_rates;
create trigger trg_fx_rates_immutable
  before update or delete on app.fx_rates
  for each row execute function app._fx_rates_immutable();

-- ---------------------------------------------------------------------
-- Lookup helper: return the active rate band for a shop right now.
-- ---------------------------------------------------------------------
create or replace function app.current_fx_band(p_shop uuid)
returns table (
  rate          numeric,
  band_low      numeric,
  band_high     numeric,
  tolerance_pct numeric,
  rate_id       uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
  select
    f.usd_to_lbp_rate,
    f.usd_to_lbp_rate * (1 - f.tolerance_pct / 100.0),
    f.usd_to_lbp_rate * (1 + f.tolerance_pct / 100.0),
    f.tolerance_pct,
    f.id
  from app.fx_rates f
  where f.shop_id = p_shop
    and f.effective_from <= now()
    and (f.effective_to is null or f.effective_to > now())
  order by f.effective_from desc
  limit 1;
$$;
revoke all on function app.current_fx_band(uuid) from public;
grant execute on function app.current_fx_band(uuid) to authenticated;

-- ---------------------------------------------------------------------
-- Constraint trigger on app.transactions:
-- when a posted rate exists for the shop, fx_rate_used must lie within
-- the band whenever the txn is a genuine USD/LBP cross-currency event.
-- ---------------------------------------------------------------------
create or replace function app._txn_fx_rate_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
  band record;
begin
  -- Skip non-money / void rows.
  if new.status <> 'completed' then return null; end if;
  if coalesce(new.fx_rate_used, 0) = 0 then return null; end if;

  -- Only police txns that actually mix the two currencies, or where
  -- the cashier deliberately recorded an fx_rate (e.g. payment in USD,
  -- gross in LBP).
  if not (coalesce(new.gross_usd,0) <> 0 and coalesce(new.gross_lbp,0) <> 0)
     and new.service_code <> 'FX_SWAP'
  then
    -- Some single-currency txns also store the day's rate for
    -- reporting; still validate it against the band so a wildly wrong
    -- value can't slip through.
    null;
  end if;

  select * into band from app.current_fx_band(new.shop_id);
  if band.rate_id is null then
    return null;          -- shop hasn't published a rate yet
  end if;

  if new.fx_rate_used < band.band_low or new.fx_rate_used > band.band_high then
    raise exception
      'fx_rate_used % outside posted band [%, %] (rate %, tolerance % %%)',
      new.fx_rate_used, band.band_low, band.band_high,
      band.rate, band.tolerance_pct;
  end if;
  return null;
end;
$$;
drop trigger if exists trg_txn_fx_rate_check on app.transactions;
create constraint trigger trg_txn_fx_rate_check
  after insert on app.transactions
  deferrable initially deferred
  for each row execute function app._txn_fx_rate_check();

-- ---------------------------------------------------------------------
-- record_fx_swap: post the two cash_movements legs atomically.
-- direction:
--   p_usd_out > 0 means USD leaves the till and LBP comes in
--                 -> fx_swap_out USD, fx_swap_in LBP
--   p_usd_out < 0 means USD comes into the till and LBP leaves
--                 -> fx_swap_in USD, fx_swap_out LBP
-- The amounts on both sides must agree to within 1 LBP at p_fx_rate,
-- and p_fx_rate must lie within the posted band (when one exists).
-- ---------------------------------------------------------------------
do $$ begin
  -- Add FX_SWAP service code if not already present, so the txn header
  -- has a real service to attach to (the recorded txn carries no
  -- product detail row).
  if not exists (select 1 from app.services where code = 'FX_SWAP') then
    insert into app.services(code, name, category)
      values ('FX_SWAP', 'Currency Exchange', 'cash_ops');
  end if;
end $$;

create or replace function app.record_fx_swap(
  p_shop      uuid,
  p_till      uuid,
  p_usd_out   numeric,    -- + USD leaves till, - USD enters till
  p_lbp_in    numeric,    -- + LBP enters till when usd_out>0, must be opposite sign
  p_fx_rate   numeric,
  p_notes     text default null
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_shift uuid;
  v_diff  numeric;
  v_txn   uuid;
  band    record;
begin
  if p_usd_out is null or p_lbp_in is null or p_fx_rate is null or p_fx_rate <= 0 then
    raise exception 'usd_out, lbp_in and positive fx_rate are required';
  end if;
  if p_usd_out = 0 then
    raise exception 'usd_out cannot be 0';
  end if;
  if sign(p_usd_out) = sign(p_lbp_in) then
    raise exception 'usd_out and lbp_in must have opposite signs (one in, one out)';
  end if;

  -- Sanity: |usd_out| * rate must equal |lbp_in| within 1 LBP.
  v_diff := abs(abs(p_usd_out) * p_fx_rate - abs(p_lbp_in));
  if v_diff > 1 then
    raise exception
      'fx swap mismatch: |usd_out|*rate = % but |lbp_in| = % (diff %)',
      abs(p_usd_out) * p_fx_rate, abs(p_lbp_in), v_diff;
  end if;

  -- Rate band (only enforced when a rate is published).
  select * into band from app.current_fx_band(p_shop);
  if band.rate_id is not null
     and (p_fx_rate < band.band_low or p_fx_rate > band.band_high)
  then
    raise exception
      'fx_rate % outside posted band [%, %] (rate %, tolerance % %%)',
      p_fx_rate, band.band_low, band.band_high, band.rate, band.tolerance_pct;
  end if;

  -- Caller must have an open shift on this till.
  select id into v_shift from app.shifts
   where till_id = p_till and shop_id = p_shop
     and user_id = auth.uid() and status = 'open'
   order by opened_at desc limit 1;
  if v_shift is null then
    raise exception 'no open shift for this till';
  end if;

  -- Create a header txn (gross_usd/lbp = 0 — money does not enter or
  -- leave the shop, just changes currency). The fx_rate is recorded.
  v_txn := app._insert_txn(p_shop, p_till, 'FX_SWAP', 'cash_usd'::app.payment_method,
    0, 0, 0, 0, 0, 0, p_fx_rate,
    null, null, null, null, null, p_notes);

  -- USD leg.
  insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
    values (
      v_shift,
      case when p_usd_out > 0
           then 'fx_swap_out'::app.cash_movement_type
           else 'fx_swap_in'::app.cash_movement_type end,
      'USD'::app.currency_code,
      -p_usd_out,                           -- p_usd_out is the OUTflow amount
      v_txn,
      'fx swap'
    );

  -- LBP leg.
  insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
    values (
      v_shift,
      case when p_lbp_in > 0
           then 'fx_swap_in'::app.cash_movement_type
           else 'fx_swap_out'::app.cash_movement_type end,
      'LBP'::app.currency_code,
      p_lbp_in,
      v_txn,
      'fx swap'
    );

  return v_txn;
end;
$$;
revoke all on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) from public;
grant execute on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) to authenticated;
