-- =====================================================================
-- Migration 0008 — Refunds, price overrides, void hardening
-- (roadmap Step 9).
--
-- Voids already exist (0003). This migration adds:
--   * Refunds as their own ledger row, never as a reverse-edit of the
--     original (vector #11).
--   * Price overrides on goods sales: only manager + PIN, capped at
--     a per-shop `max_discount_pct`, fully audited (vector #12).
--   * Void/refund/override summary views per cashier and per
--     (cashier, manager) pair to expose collusion (vector #18).
--
-- Threat-model rows addressed: 11, 12, 18.
-- =====================================================================

-- =====================================================================
-- Refunds
-- =====================================================================
-- A refund is recorded as a transaction with service_code 'REFUND'
-- linked back to the original txn via app.refunds. Money signs are kept
-- positive on the row; cash flows are negative for the shop and are
-- reflected via paired cash_movements / float_movements just like sales.
insert into app.services(code, name, category) values
  ('REFUND', 'Customer Refund', 'refund')
  on conflict (code) do nothing;

create table if not exists app.refunds (
  id                  uuid primary key default gen_random_uuid(),
  refund_txn_id       uuid not null references app.transactions(id) on delete restrict,
  original_txn_id     uuid not null references app.transactions(id) on delete restrict,
  reason              text not null,
  manager_approved_by uuid not null references auth.users(id),
  amount_usd          numeric(14,2) not null default 0 check (amount_usd >= 0),
  amount_lbp          numeric(18,0) not null default 0 check (amount_lbp >= 0),
  created_at          timestamptz not null default now(),
  constraint refunds_no_self check (refund_txn_id <> original_txn_id),
  constraint refunds_unique_refund_txn unique (refund_txn_id)
);
create index if not exists idx_refunds_original on app.refunds(original_txn_id);

create or replace function app._refunds_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'refunds is append-only'; end;
$$;
drop trigger if exists trg_refunds_freeze on app.refunds;
create trigger trg_refunds_freeze before update or delete on app.refunds
  for each row execute function app._refunds_no_update_delete();

-- The single legal way to issue a refund. Enforces manager role + PIN,
-- amount ≤ original (minus any prior refunds), original is completed,
-- and creates the refund txn + linkage atomically.
create or replace function app.issue_refund(
  p_original_txn  uuid,
  p_amount_usd    numeric,
  p_amount_lbp    numeric,
  p_reason        text,
  p_manager_pin   text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  o            app.transactions%rowtype;
  s            app.shifts%rowtype;
  prior_usd    numeric := 0;
  prior_lbp    numeric := 0;
  refund_id    uuid;
  refund_txn   uuid;
begin
  if p_amount_usd is null or p_amount_lbp is null
     or p_amount_usd < 0 or p_amount_lbp < 0
     or (p_amount_usd = 0 and p_amount_lbp = 0) then
    raise exception 'refund amount must be >= 0 and at least one currency > 0';
  end if;
  if p_reason is null or length(btrim(p_reason)) < 5 then
    raise exception 'reason >= 5 chars required';
  end if;

  select * into o from app.transactions where id = p_original_txn;
  if o.id is null then raise exception 'original txn not found'; end if;
  if o.status <> 'completed' then
    raise exception 'cannot refund a % transaction', o.status;
  end if;

  -- Caller must be a manager in the same shop and prove it via PIN.
  if not app.has_role_in_shop(o.shop_id, 'manager') then
    raise exception 'manager role required to issue a refund';
  end if;
  if not app.verify_my_pin(p_manager_pin) then
    raise exception 'invalid manager PIN';
  end if;

  -- Refund must be issued on the manager's currently open shift in
  -- this shop (so the cash leaves the right till).
  select * into s from app.shifts
    where shop_id = o.shop_id and status = 'open' and user_id = auth.uid()
    limit 1;
  if s.id is null then
    raise exception 'manager has no open shift in shop % to issue the refund from', o.shop_id;
  end if;

  -- Prior refunds against this original.
  select coalesce(sum(amount_usd),0), coalesce(sum(amount_lbp),0)
    into prior_usd, prior_lbp
    from app.refunds where original_txn_id = p_original_txn;

  if (prior_usd + p_amount_usd) > o.gross_usd then
    raise exception 'refund USD exceeds remaining refundable amount (% > %)',
      prior_usd + p_amount_usd, o.gross_usd;
  end if;
  if (prior_lbp + p_amount_lbp) > o.gross_lbp then
    raise exception 'refund LBP exceeds remaining refundable amount (% > %)',
      prior_lbp + p_amount_lbp, o.gross_lbp;
  end if;

  -- Create the refund transaction. The standard txn triggers (server
  -- stamping, hash chain, sequence) all apply.
  insert into app.transactions(
    shift_id, shop_id, till_id, user_id, service_code,
    gross_usd, gross_lbp, fee_usd, fee_lbp,
    payment_method, notes
  ) values (
    s.id, o.shop_id, s.till_id, auth.uid(), 'REFUND',
    p_amount_usd, p_amount_lbp, 0, 0,
    o.payment_method, 'refund of ' || o.id::text || ' — ' || p_reason
  ) returning id into refund_txn;

  insert into app.refunds(refund_txn_id, original_txn_id, reason,
                          manager_approved_by, amount_usd, amount_lbp)
    values (refund_txn, p_original_txn, p_reason, auth.uid(),
            p_amount_usd, p_amount_lbp)
    returning id into refund_id;

  -- Cash leaves the till (negative cash_movements). Currency split.
  if p_amount_usd > 0 then
    insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
      values (s.id, 'payout_out', 'USD', -p_amount_usd, refund_txn, 'refund');
  end if;
  if p_amount_lbp > 0 then
    insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
      values (s.id, 'payout_out', 'LBP', -p_amount_lbp, refund_txn, 'refund');
  end if;

  perform app.log_auth_event('refund_issued', o.shop_id, null,
    jsonb_build_object('original', p_original_txn, 'refund_txn', refund_txn,
                       'amount_usd', p_amount_usd, 'amount_lbp', p_amount_lbp));

  return refund_txn;
end;
$$;

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

-- The REFUND service does not need a child detail row; teach the
-- detail-required check to skip it.
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;
    when 'REFUND'           then select exists(select 1 from app.refunds               where refund_txn_id = new.id) into ok;
    else ok := true;
  end case;
  if not ok then
    raise exception 'transaction % (service %) is missing its detail/refund row',
      new.id, new.service_code;
  end if;
  return null;
end;
$$;

-- =====================================================================
-- Price overrides on goods sales
-- =====================================================================
-- Per-shop policy: maximum discount % a manager can authorize without
-- escalating to owner.
create table if not exists app.shop_pricing_policy (
  shop_id           uuid primary key references app.shops(id) on delete cascade,
  max_discount_pct  numeric(5,2) not null default 10.00 check (max_discount_pct between 0 and 50),
  updated_at        timestamptz not null default now(),
  updated_by        uuid references auth.users(id)
);

-- Append-only audit table for every override.
create table if not exists app.price_overrides (
  id              uuid primary key default gen_random_uuid(),
  txn_id          uuid not null references app.transactions(id) on delete restrict,
  sku             text not null references app.items(sku),
  list_price_usd  numeric(14,2) not null check (list_price_usd > 0),
  sold_price_usd  numeric(14,2) not null check (sold_price_usd >= 0),
  discount_pct    numeric(6,2)  not null,
  reason          text not null,
  approved_by     uuid not null references auth.users(id),
  approver_role   app.business_role not null,
  created_at      timestamptz not null default now(),
  constraint price_override_unique_per_txn_sku unique (txn_id, sku)
);
create index if not exists idx_price_ovr_txn on app.price_overrides(txn_id);

create or replace function app._price_overrides_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'price_overrides is append-only'; end;
$$;
drop trigger if exists trg_price_ovr_freeze on app.price_overrides;
create trigger trg_price_ovr_freeze before update or delete on app.price_overrides
  for each row execute function app._price_overrides_no_update_delete();

-- Definer function: the only legal way to authorize a discount.
-- Returns the approved sold_price; caller passes it into the goods
-- sale flow.
create or replace function app.authorize_price_override(
  p_txn_id        uuid,
  p_sku           text,
  p_sold_price    numeric,
  p_reason        text,
  p_manager_pin   text
) returns numeric
language plpgsql
security definer
set search_path = app, public
as $$
declare
  t          app.transactions%rowtype;
  i          app.items%rowtype;
  pol        app.shop_pricing_policy%rowtype;
  pct        numeric;
  role_used  app.business_role;
begin
  if p_sold_price is null or p_sold_price < 0 then
    raise exception 'sold price must be >= 0';
  end if;
  if p_reason is null or length(btrim(p_reason)) < 5 then
    raise exception 'reason >= 5 chars required';
  end if;

  select * into t from app.transactions where id = p_txn_id;
  if t.id is null then raise exception 'txn not found'; end if;
  if t.status <> 'completed' then
    raise exception 'cannot override price on a % transaction', t.status;
  end if;

  select * into i from app.items where sku = p_sku;
  if i.sku is null then raise exception 'sku not found'; end if;

  if p_sold_price > i.price_usd then
    raise exception 'sold price > list price; not an override';
  end if;
  pct := round(((i.price_usd - p_sold_price) / nullif(i.price_usd,0)) * 100.0, 2);

  -- Caller must be manager or owner in this shop AND give a valid PIN.
  if app.has_role_in_shop(t.shop_id, 'owner') then
    role_used := 'owner';
  elsif app.has_role_in_shop(t.shop_id, 'manager') then
    role_used := 'manager';
  else
    raise exception 'manager or owner role required to override price';
  end if;
  if not app.verify_my_pin(p_manager_pin) then
    raise exception 'invalid PIN';
  end if;

  -- Check shop policy ceiling for managers. Owners can go beyond.
  select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
  if not found then
    insert into app.shop_pricing_policy(shop_id) values (t.shop_id)
      on conflict (shop_id) do nothing;
    select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
  end if;
  if role_used = 'manager' and pct > pol.max_discount_pct then
    raise exception 'discount % %% exceeds shop ceiling % %% (owner approval needed)',
      pct, pol.max_discount_pct;
  end if;

  insert into app.price_overrides(
    txn_id, sku, list_price_usd, sold_price_usd, discount_pct,
    reason, approved_by, approver_role
  ) values (
    p_txn_id, p_sku, i.price_usd, p_sold_price, pct,
    p_reason, auth.uid(), role_used
  );

  perform app.log_auth_event('price_override', t.shop_id, null,
    jsonb_build_object('txn', p_txn_id, 'sku', p_sku, 'pct', pct,
                       'role', role_used));

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

-- A goods_sale_details row priced below list price MUST have a matching
-- price_overrides row (deferred so the override can be inserted in the
-- same transaction).
create or replace function app._goods_sale_require_override_if_discounted()
returns trigger language plpgsql as $$
declare i app.items%rowtype;
        has_ovr boolean;
begin
  select * into i from app.items where sku = new.sku;
  if i.sku is null then return null; end if;       -- FK will catch it
  if new.unit_price_usd < i.price_usd then
    select exists(
      select 1 from app.price_overrides
      where txn_id = new.txn_id and sku = new.sku
        and sold_price_usd = new.unit_price_usd
    ) into has_ovr;
    if not has_ovr then
      raise exception
        'goods sale of % below list price (% < %) requires an authorized price override',
        new.sku, new.unit_price_usd, i.price_usd;
    end if;
  end if;
  return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_override on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_override
  after insert on app.goods_sale_details
  deferrable initially deferred
  for each row execute function app._goods_sale_require_override_if_discounted();

-- =====================================================================
-- Reporting views — collusion / abuse signals (vector #18)
-- =====================================================================
-- Daily voids per cashier
create or replace view app.v_voids_by_cashier_day as
select t.shop_id,
       (t.occurred_at at time zone 'UTC')::date as day,
       t.user_id                                as cashier_id,
       count(*)                                  as void_count,
       sum(t.gross_usd)                          as voided_usd,
       sum(t.gross_lbp)                          as voided_lbp
from app.transactions t
where t.status = 'voided'
group by 1,2,3;

-- Daily refunds per cashier (the cashier of the original txn)
create or replace view app.v_refunds_by_original_cashier_day as
select o.shop_id,
       (r.created_at at time zone 'UTC')::date as day,
       o.user_id                                as original_cashier_id,
       r.manager_approved_by                    as approving_manager_id,
       count(*)                                  as refund_count,
       sum(r.amount_usd)                         as refunded_usd,
       sum(r.amount_lbp)                         as refunded_lbp
from app.refunds r
join app.transactions o on o.id = r.original_txn_id
group by 1,2,3,4;

-- Cashier–manager pairs with high void+refund volume (collusion signal)
create or replace view app.v_void_refund_pairs as
select t.shop_id,
       t.user_id            as cashier_id,
       t.void_approved_by   as manager_id,
       date_trunc('week', t.voided_at) as week_bucket,
       count(*)             as void_count,
       sum(t.gross_usd)     as voided_usd
from app.transactions t
where t.status = 'voided' and t.void_approved_by is not null
group by 1,2,3,4
having count(*) >= 5;

-- Price-override volume by approver
create or replace view app.v_overrides_by_approver_day as
select t.shop_id,
       (po.created_at at time zone 'UTC')::date as day,
       po.approved_by,
       po.approver_role,
       count(*)                                  as override_count,
       sum(po.list_price_usd - po.sold_price_usd) as discount_total_usd,
       avg(po.discount_pct)                       as avg_discount_pct
from app.price_overrides po
join app.transactions t on t.id = po.txn_id
group by 1,2,3,4;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.refunds              enable row level security;
alter table app.shop_pricing_policy  enable row level security;
alter table app.price_overrides      enable row level security;
alter table app.refunds              force row level security;
alter table app.shop_pricing_policy  force row level security;
alter table app.price_overrides      force row level security;

revoke insert, update, delete on app.refunds              from authenticated;
revoke insert, update, delete on app.price_overrides      from authenticated;
revoke insert, update, delete on app.shop_pricing_policy  from authenticated;

drop policy if exists refunds_select on app.refunds;
create policy refunds_select on app.refunds
  for select to authenticated
  using (app._can_see_txn(refund_txn_id));
grant select on app.refunds to authenticated;

drop policy if exists pricing_policy_select on app.shop_pricing_policy;
create policy pricing_policy_select on app.shop_pricing_policy
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','auditor']::app.business_role[])
  );
grant select on app.shop_pricing_policy to authenticated;

drop policy if exists price_ovr_select on app.price_overrides;
create policy price_ovr_select on app.price_overrides
  for select to authenticated
  using (app._can_see_txn(txn_id));
grant select on app.price_overrides to authenticated;

-- End migration 0008 ----------------------------------------------------
