-- =====================================================================
-- Migration 0005 — Inventory and e-float (roadmap Step 6).
--
-- Two parallel stock systems for a cell shop:
--
--   1. Physical inventory: scratch cards (with serials), SIMs, phones,
--      accessories. Voucher serials track per-card lifecycle so the
--      same card can never be sold twice and "lost" cards are visible.
--
--   2. Electronic float: OMT cash float, Alfa/touch e-recharge wallet,
--      whish, etc. Every recharge or transfer must move e-float in
--      lockstep with cash, otherwise reconciliation fails.
--
-- Threat-model rows addressed: 3, 4, 5, 13, 21, 22.
-- =====================================================================

-- =====================================================================
-- Items and physical stock
-- =====================================================================
do $$ begin
  create type app.item_type as enum (
    'scratch_card', 'sim', 'phone', 'accessory', 'consumable'
  );
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.stock_movement_type as enum (
    'purchase_in',     -- received from distributor
    'sale_out',        -- linked to a transaction
    'return_in',       -- customer return
    'damaged_out',     -- write-off (manager approval)
    'lost_out',        -- write-off (manager approval)
    'transfer_in',     -- between shops
    'transfer_out',
    'adjustment_in',   -- audited correction
    'adjustment_out'
  );
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.voucher_status as enum (
    'in_stock', 'sold', 'damaged', 'lost', 'returned'
  );
exception when duplicate_object then null; end $$;

create table if not exists app.items (
  sku             text primary key,
  name            text not null,
  type            app.item_type not null,
  operator        text,                            -- 'ALFA','TOUCH', null for non-recharge
  face_value_usd  numeric(14,2),                   -- recharge denomination if applicable
  cost_usd        numeric(14,2) not null check (cost_usd >= 0),
  price_usd       numeric(14,2) not null check (price_usd >= 0),
  is_active       boolean not null default true,
  created_at      timestamptz not null default now()
);

-- Per-shop stock-on-hand counter (denormalized, kept in sync by trigger).
create table if not exists app.stock_on_hand (
  sku        text not null references app.items(sku),
  shop_id    uuid not null references app.shops(id),
  qty        integer not null default 0 check (qty >= 0),
  primary key (sku, shop_id)
);

create table if not exists app.stock_lots (
  id              uuid primary key default gen_random_uuid(),
  sku             text not null references app.items(sku),
  shop_id         uuid not null references app.shops(id),
  received_at     timestamptz not null default now(),
  qty_received    integer not null check (qty_received > 0),
  unit_cost_usd   numeric(14,2) not null check (unit_cost_usd >= 0),
  supplier        text,
  invoice_no      text,
  received_by     uuid not null references auth.users(id) default auth.uid(),
  created_at      timestamptz not null default now()
);
create index if not exists idx_stock_lots_sku_shop on app.stock_lots(sku, shop_id);

-- Append-only stock movements ledger -----------------------------------
create table if not exists app.stock_movements (
  id            uuid primary key default gen_random_uuid(),
  sku           text not null references app.items(sku),
  shop_id       uuid not null references app.shops(id),
  shift_id      uuid references app.shifts(id),
  type          app.stock_movement_type not null,
  -- Signed: positive = +stock (purchase_in, return_in, transfer_in, adjustment_in)
  --         negative = -stock (sale_out, damaged_out, lost_out, transfer_out, adjustment_out)
  qty_delta     integer not null check (qty_delta <> 0),
  ref_txn_id    uuid references app.transactions(id),
  ref_lot_id    uuid references app.stock_lots(id),
  approved_by   uuid references auth.users(id),     -- required for damaged/lost/adjustment
  reason        text,
  created_at    timestamptz not null default now(),
  created_by    uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_stock_mov_sku_shop on app.stock_movements(sku, shop_id, created_at desc);
create index if not exists idx_stock_mov_txn      on app.stock_movements(ref_txn_id);

-- =====================================================================
-- Voucher inventory (per-serial lifecycle)
-- =====================================================================
create table if not exists app.voucher_inventory (
  serial       text primary key,
  sku          text not null references app.items(sku),
  shop_id      uuid not null references app.shops(id),
  lot_id       uuid references app.stock_lots(id),
  status       app.voucher_status not null default 'in_stock',
  received_at  timestamptz not null default now(),
  sold_txn_id  uuid references app.transactions(id),
  sold_at      timestamptz,
  status_changed_by uuid references auth.users(id),
  status_change_reason text,
  constraint voucher_status_consistency check (
       (status = 'in_stock' and sold_txn_id is null  and sold_at is null)
    or (status = 'sold'     and sold_txn_id is not null and sold_at is not null)
    or (status in ('damaged','lost','returned')
        and sold_txn_id is null and sold_at is null)
  )
);
create index if not exists idx_voucher_status   on app.voucher_inventory(status);
create index if not exists idx_voucher_sku_shop on app.voucher_inventory(sku, shop_id);

-- =====================================================================
-- E-float (OMT cash float, Alfa e-recharge wallet, etc.)
-- =====================================================================
do $$ begin
  create type app.float_provider as enum (
    'OMT_CASH', 'OMT_DIGITAL', 'ALFA_ERECHARGE', 'TOUCH_ERECHARGE',
    'OGERO_ERECHARGE', 'WHISH', 'CARD_TERMINAL', 'BANK'
  );
exception when duplicate_object then null; end $$;

create table if not exists app.floats (
  id          uuid primary key default gen_random_uuid(),
  shop_id     uuid not null references app.shops(id) on delete restrict,
  provider    app.float_provider not null,
  currency    app.currency_code not null,
  is_active   boolean not null default true,
  created_at  timestamptz not null default now(),
  unique (shop_id, provider, currency)
);

-- Cached balance per float, kept in sync by the movements trigger.
create table if not exists app.float_balances (
  float_id    uuid primary key references app.floats(id) on delete cascade,
  balance     numeric(20,2) not null default 0,
  updated_at  timestamptz not null default now()
);

create table if not exists app.float_movements (
  id            uuid primary key default gen_random_uuid(),
  float_id      uuid not null references app.floats(id) on delete restrict,
  shift_id      uuid references app.shifts(id),
  occurred_at   timestamptz not null default now(),
  -- Signed: + adds to e-float, - removes from it.
  amount        numeric(20,2) not null check (amount <> 0),
  ref_txn_id    uuid references app.transactions(id),
  ref_settlement_id uuid,                            -- FK added in 0007
  reason        text,
  created_at    timestamptz not null default now(),
  created_by    uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_float_mov_float on app.float_movements(float_id, occurred_at);
create index if not exists idx_float_mov_txn   on app.float_movements(ref_txn_id);

-- =====================================================================
-- Triggers — append-only, balance maintenance, no negative stock
-- =====================================================================

-- Stock movements: append-only.
create or replace function app._stock_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'stock_movements is append-only'; end;
$$;
drop trigger if exists trg_stock_mov_freeze on app.stock_movements;
create trigger trg_stock_mov_freeze before update or delete on app.stock_movements
  for each row execute function app._stock_mov_no_update_delete();

-- Stock movements: server-stamped, sign matches type, optional manager
-- approval enforced for write-offs.
create or replace function app._stock_mov_before_insert()
returns trigger language plpgsql as $$
begin
  new.created_at := now();
  new.created_by := auth.uid();

  -- Sign / type consistency.
  if new.type in ('purchase_in','return_in','transfer_in','adjustment_in')
     and new.qty_delta <= 0 then
    raise exception '% must have qty_delta > 0', new.type;
  end if;
  if new.type in ('sale_out','damaged_out','lost_out','transfer_out','adjustment_out')
     and new.qty_delta >= 0 then
    raise exception '% must have qty_delta < 0', new.type;
  end if;

  -- Write-offs and adjustments need manager approval.
  if new.type in ('damaged_out','lost_out','adjustment_in','adjustment_out')
     and new.approved_by is null then
    raise exception '% requires manager approval (approved_by)', new.type;
  end if;

  -- sale_out must reference a real, completed sale of the same shop.
  if new.type = 'sale_out' then
    if new.ref_txn_id is null then
      raise exception 'sale_out requires ref_txn_id';
    end if;
    if not exists (
      select 1 from app.transactions
      where id = new.ref_txn_id and shop_id = new.shop_id and status = 'completed'
    ) then
      raise exception 'sale_out must reference a completed txn in the same shop';
    end if;
  end if;
  return new;
end;
$$;
drop trigger if exists trg_stock_mov_before_insert on app.stock_movements;
create trigger trg_stock_mov_before_insert before insert on app.stock_movements
  for each row execute function app._stock_mov_before_insert();

-- Maintain stock_on_hand. No negative balance allowed.
create or replace function app._stock_on_hand_apply()
returns trigger language plpgsql as $$
begin
  insert into app.stock_on_hand(sku, shop_id, qty)
    values (new.sku, new.shop_id, new.qty_delta)
  on conflict (sku, shop_id) do update
    set qty = app.stock_on_hand.qty + new.qty_delta;
  -- Re-check; the CHECK on the table will already reject negatives but
  -- give a clearer error here.
  if (select qty from app.stock_on_hand
      where sku = new.sku and shop_id = new.shop_id) < 0 then
    raise exception 'stock would go negative for sku=% shop=%', new.sku, new.shop_id;
  end if;
  return null;
end;
$$;
drop trigger if exists trg_stock_on_hand_apply on app.stock_movements;
create trigger trg_stock_on_hand_apply after insert on app.stock_movements
  for each row execute function app._stock_on_hand_apply();

-- Stock_lots: receiving stock auto-creates a purchase_in movement.
create or replace function app._stock_lot_after_insert()
returns trigger language plpgsql as $$
begin
  insert into app.stock_movements(sku, shop_id, type, qty_delta, ref_lot_id, reason)
    values (new.sku, new.shop_id, 'purchase_in', new.qty_received, new.id,
            coalesce('lot ' || new.invoice_no, 'lot received'));
  return null;
end;
$$;
drop trigger if exists trg_stock_lot_after_insert on app.stock_lots;
create trigger trg_stock_lot_after_insert after insert on app.stock_lots
  for each row execute function app._stock_lot_after_insert();

-- Float movements: append-only + balance.
create or replace function app._float_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'float_movements is append-only'; end;
$$;
drop trigger if exists trg_float_mov_freeze on app.float_movements;
create trigger trg_float_mov_freeze before update or delete on app.float_movements
  for each row execute function app._float_mov_no_update_delete();

create or replace function app._float_balance_apply()
returns trigger language plpgsql as $$
begin
  insert into app.float_balances(float_id, balance, updated_at)
    values (new.float_id, new.amount, now())
  on conflict (float_id) do update
    set balance    = app.float_balances.balance + new.amount,
        updated_at = now();
  if (select balance from app.float_balances where float_id = new.float_id) < 0 then
    raise exception 'float would go negative for float_id=%', new.float_id;
  end if;
  return null;
end;
$$;
drop trigger if exists trg_float_balance_apply on app.float_movements;
create trigger trg_float_balance_apply after insert on app.float_movements
  for each row execute function app._float_balance_apply();

-- =====================================================================
-- Recharge ↔ stock/float coupling
-- A recharge_details row MUST move either physical stock (voucher) or
-- e-float, otherwise it is a free recharge — exactly the fraud we want
-- to make impossible (vectors #3, #21).
-- Implemented as a deferred constraint trigger so the client can write
-- the recharge row first, then the movement, in a single transaction.
-- =====================================================================
create or replace function app._recharge_require_movement()
returns trigger
language plpgsql
as $$
declare
  has_voucher_movement boolean;
  has_float_movement   boolean;
  v_provider           app.float_provider;
begin
  if new.voucher_serial is not null then
    -- The voucher must be marked sold and tied to this txn.
    select exists(
      select 1 from app.voucher_inventory
      where serial = new.voucher_serial
        and status = 'sold'
        and sold_txn_id = new.txn_id
    ) into has_voucher_movement;
    if not has_voucher_movement then
      raise exception
        'recharge with voucher_serial=% must be paired with a sold voucher',
        new.voucher_serial;
    end if;
  else
    -- E-recharge: an e-float debit must exist for this txn against the
    -- matching operator's e-float account.
    v_provider := case new.operator
      when 'ALFA'     then 'ALFA_ERECHARGE'::app.float_provider
      when 'TOUCH'    then 'TOUCH_ERECHARGE'::app.float_provider
      when 'OGERO'    then 'OGERO_ERECHARGE'::app.float_provider
      else null
    end;
    if v_provider is null then
      -- Unmapped operator (IDM, CYBERIA, TERRANET): require any negative
      -- float movement for this txn.
      select exists(
        select 1 from app.float_movements
        where ref_txn_id = new.txn_id and amount < 0
      ) into has_float_movement;
    else
      select exists(
        select 1
        from app.float_movements fm
        join app.floats f on f.id = fm.float_id
        join app.transactions t on t.id = fm.ref_txn_id
        where fm.ref_txn_id = new.txn_id
          and fm.amount < 0
          and f.provider = v_provider
          and f.shop_id  = t.shop_id
      ) into has_float_movement;
    end if;
    if not has_float_movement then
      raise exception
        'e-recharge txn % must be paired with a negative e-float movement',
        new.txn_id;
    end if;
  end if;
  return null;
end;
$$;

drop trigger if exists trg_recharge_require_movement on app.recharge_details;
create constraint trigger trg_recharge_require_movement
  after insert on app.recharge_details
  deferrable initially deferred
  for each row execute function app._recharge_require_movement();

-- Goods sale ↔ stock_movement coupling (same idea).
create or replace function app._goods_sale_require_movement()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
  select exists(
    select 1 from app.stock_movements sm
    where sm.ref_txn_id = new.txn_id
      and sm.sku        = new.sku
      and sm.type       = 'sale_out'
      and -sm.qty_delta = new.qty
  ) into ok;
  if not ok then
    raise exception 'goods sale txn % must be paired with a sale_out stock movement', new.txn_id;
  end if;
  return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_movement on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_movement
  after insert on app.goods_sale_details
  deferrable initially deferred
  for each row execute function app._goods_sale_require_movement();

-- =====================================================================
-- SECURITY DEFINER helpers used by the cashier UI
-- =====================================================================

-- Sell a scratch card: marks the voucher sold + creates the stock_out.
-- Called inside the same transaction as inserting the txn + recharge_details.
create or replace function app.sell_voucher(
  p_txn_id uuid,
  p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
        t app.transactions%rowtype;
begin
  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.user_id <> auth.uid() then
    raise exception 'only the txn owner may sell a voucher against it';
  end if;

  select * into v from app.voucher_inventory where serial = p_serial for update;
  if v.serial is null then raise exception 'voucher % not found', p_serial; end if;
  if v.shop_id <> t.shop_id then
    raise exception 'voucher belongs to a different shop';
  end if;
  if v.status <> 'in_stock' then
    raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
  end if;

  update app.voucher_inventory
    set status = 'sold', sold_txn_id = p_txn_id, sold_at = now(),
        status_changed_by = auth.uid()
    where serial = p_serial;

  insert into app.stock_movements(sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason)
    values (v.sku, v.shop_id, t.shift_id, 'sale_out', -1, p_txn_id, 'voucher ' || p_serial);
end;
$$;

-- Mark a voucher damaged or lost (manager only, with PIN).
create or replace function app.write_off_voucher(
  p_serial text,
  p_status app.voucher_status,
  p_reason text,
  p_manager_pin text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
begin
  if p_status not in ('damaged','lost') then
    raise exception 'only damaged/lost are valid write-off statuses';
  end if;
  if p_reason is null or length(btrim(p_reason)) < 5 then
    raise exception 'reason >= 5 chars required';
  end if;
  select * into v from app.voucher_inventory where serial = p_serial for update;
  if v.serial is null then raise exception 'voucher not found'; end if;
  if v.status <> 'in_stock' then
    raise exception 'voucher must be in_stock to write off (was %)', v.status;
  end if;
  if not app.has_role_in_shop(v.shop_id, 'manager') then
    raise exception 'manager role required';
  end if;
  if not app.verify_my_pin(p_manager_pin) then
    raise exception 'invalid manager PIN';
  end if;

  update app.voucher_inventory
    set status = p_status, status_changed_by = auth.uid(),
        status_change_reason = p_reason
    where serial = p_serial;

  insert into app.stock_movements(sku, shop_id, type, qty_delta, approved_by, reason)
    values (v.sku, v.shop_id,
            case p_status when 'damaged' then 'damaged_out'::app.stock_movement_type
                          when 'lost'    then 'lost_out'::app.stock_movement_type end,
            -1, auth.uid(), p_reason);
end;
$$;

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

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.items             enable row level security;
alter table app.stock_on_hand     enable row level security;
alter table app.stock_lots        enable row level security;
alter table app.stock_movements   enable row level security;
alter table app.voucher_inventory enable row level security;
alter table app.floats            enable row level security;
alter table app.float_balances    enable row level security;
alter table app.float_movements   enable row level security;

alter table app.items             force row level security;
alter table app.stock_on_hand     force row level security;
alter table app.stock_lots        force row level security;
alter table app.stock_movements   force row level security;
alter table app.voucher_inventory force row level security;
alter table app.floats            force row level security;
alter table app.float_balances    force row level security;
alter table app.float_movements   force row level security;

-- Block direct UPDATE/DELETE on append-only tables.
revoke update, delete on app.stock_movements   from authenticated;
revoke update, delete on app.float_movements   from authenticated;
revoke update, delete on app.voucher_inventory from authenticated;
revoke update, delete on app.stock_on_hand     from authenticated;
revoke update, delete on app.float_balances    from authenticated;
-- Items are reference data: only owners may modify (handled by policy).

-- Items: readable by everyone authenticated; writes for owners only.
drop policy if exists items_select on app.items;
create policy items_select on app.items for select to authenticated using (true);
drop policy if exists items_write_owner on app.items;
create policy items_write_owner on app.items
  for all to authenticated
  using (app.is_owner_anywhere())
  with check (app.is_owner_anywhere());

-- Shop-scoped tables: readable to anyone assigned to the shop.
drop policy if exists soh_select on app.stock_on_hand;
create policy soh_select on app.stock_on_hand
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','cashier','auditor']::app.business_role[])
  );

drop policy if exists lots_select on app.stock_lots;
create policy lots_select on app.stock_lots
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','auditor']::app.business_role[])
  );
drop policy if exists lots_insert on app.stock_lots;
create policy lots_insert on app.stock_lots
  for insert to authenticated
  with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.stock_lots to authenticated;

drop policy if exists smov_select on app.stock_movements;
create policy smov_select on app.stock_movements
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','cashier','auditor']::app.business_role[])
  );
drop policy if exists smov_insert on app.stock_movements;
create policy smov_insert on app.stock_movements
  for insert to authenticated
  with check (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','cashier']::app.business_role[])
  );
grant select, insert on app.stock_movements to authenticated;

drop policy if exists vouch_select on app.voucher_inventory;
create policy vouch_select on app.voucher_inventory
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','cashier','auditor']::app.business_role[])
  );
drop policy if exists vouch_insert on app.voucher_inventory;
create policy vouch_insert on app.voucher_inventory
  for insert to authenticated
  with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.voucher_inventory to authenticated;
-- Voucher status changes go through SECURITY DEFINER functions only.

drop policy if exists floats_select on app.floats;
create policy floats_select on app.floats
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','cashier','auditor']::app.business_role[])
  );
drop policy if exists floats_write_owner on app.floats;
create policy floats_write_owner on app.floats
  for all to authenticated
  using (app.has_role_in_shop(shop_id, 'owner'))
  with check (app.has_role_in_shop(shop_id, 'owner'));
grant select, insert, update on app.floats to authenticated;

drop policy if exists fbal_select on app.float_balances;
create policy fbal_select on app.float_balances
  for select to authenticated
  using (
    exists (
      select 1 from app.floats f
      where f.id = float_balances.float_id
        and app.has_any_role_in_shop(f.shop_id,
              array['owner','manager','cashier','auditor']::app.business_role[])
    )
  );

drop policy if exists fmov_select on app.float_movements;
create policy fmov_select on app.float_movements
  for select to authenticated
  using (
    exists (
      select 1 from app.floats f
      where f.id = float_movements.float_id
        and app.has_any_role_in_shop(f.shop_id,
              array['owner','manager','cashier','auditor']::app.business_role[])
    )
  );
drop policy if exists fmov_insert on app.float_movements;
create policy fmov_insert on app.float_movements
  for insert to authenticated
  with check (
    exists (
      select 1 from app.floats f
      where f.id = float_movements.float_id
        and app.has_any_role_in_shop(f.shop_id,
              array['owner','manager','cashier']::app.business_role[])
    )
  );
grant select, insert on app.float_movements to authenticated;

grant select on app.items, app.stock_on_hand, app.float_balances to authenticated;

-- End migration 0005 ----------------------------------------------------
