-- =====================================================================
-- Migration 0002 — Shifts and cash drawer (roadmap Step 3).
--
-- Implements the cash-control backbone:
--   * One open shift per till at any time (vector #20).
--   * Blind close: cashier declares cash, expected is computed by the
--     system; both are stored, with variance (vectors #7, #14).
--   * Cash movements typed and append-only (vector #2, #22).
--   * No backdating: occurred_at = now() server-side (vector #25).
--   * Forced shift close before next shift opens (vector #8).
--
-- Threat-model rows addressed: 2, 7, 8, 14, 20, 22, 25.
-- =====================================================================

-- =====================================================================
-- Enums
-- =====================================================================
do $$ begin
  create type app.shift_status as enum ('open', 'declared', 'closed');
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.cash_movement_type as enum (
    'sale_in',          -- cash received from a customer
    'payout_out',       -- cash paid to a customer (e.g. OMT receive)
    'drop_to_safe',     -- cashier removes cash from till to safe
    'bank_deposit',     -- cash leaves the shop to the bank
    'expense',          -- petty cash spent
    'fx_swap_in',       -- one leg of a currency exchange
    'fx_swap_out',      -- the other leg
    'opening_float',    -- recorded at shift open
    'adjustment'        -- manager-approved correction (always audited)
  );
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.currency_code as enum ('USD', 'LBP');
exception when duplicate_object then null; end $$;

-- =====================================================================
-- Shifts
-- =====================================================================
create table if not exists app.shifts (
  id                    uuid primary key default gen_random_uuid(),
  till_id               uuid not null references app.tills(id)  on delete restrict,
  shop_id               uuid not null references app.shops(id)  on delete restrict,
  user_id               uuid not null references auth.users(id) on delete restrict,

  status                app.shift_status not null default 'open',

  opened_at             timestamptz not null default now(),
  opened_by             uuid not null references auth.users(id),
  opening_usd           numeric(14,2) not null check (opening_usd >= 0),
  opening_lbp           numeric(18,0) not null check (opening_lbp >= 0),

  -- Phase 1 of close: cashier declares the count.
  declared_at           timestamptz,
  declared_close_usd    numeric(14,2) check (declared_close_usd >= 0),
  declared_close_lbp    numeric(18,0) check (declared_close_lbp >= 0),

  -- Phase 2 of close: system computes expected; cashier cannot edit.
  closed_at             timestamptz,
  closed_by             uuid references auth.users(id),
  expected_close_usd    numeric(14,2),
  expected_close_lbp    numeric(18,0),
  variance_usd          numeric(14,2),
  variance_lbp          numeric(18,0),

  -- One open or declared shift per till at any time.
  constraint shifts_status_dates_ok check (
    (status = 'open'     and declared_at is null and closed_at is null)
    or (status = 'declared' and declared_at is not null and closed_at is null)
    or (status = 'closed'   and declared_at is not null and closed_at is not null)
  )
);

create index if not exists idx_shifts_till_status on app.shifts(till_id, status);
create index if not exists idx_shifts_shop_open   on app.shifts(shop_id, opened_at desc);
create index if not exists idx_shifts_user        on app.shifts(user_id, opened_at desc);

-- Partial unique index: at most one non-closed shift per till.
create unique index if not exists uq_one_active_shift_per_till
  on app.shifts(till_id) where status <> 'closed';

-- =====================================================================
-- Cash movements (append-only)
-- =====================================================================
create table if not exists app.cash_movements (
  id            uuid primary key default gen_random_uuid(),
  shift_id      uuid not null references app.shifts(id) on delete restrict,
  occurred_at   timestamptz not null default now(),
  type          app.cash_movement_type not null,
  currency      app.currency_code not null,
  -- Signed amount: positive = cash into the till, negative = cash out.
  amount        numeric(18,2) not null check (amount <> 0),
  ref_txn_id    uuid,    -- filled later when ledger table exists (FK added in 0003)
  note          text,
  created_by    uuid not null references auth.users(id) default auth.uid(),
  created_at    timestamptz not null default now()
);

create index if not exists idx_cash_mov_shift on app.cash_movements(shift_id, occurred_at);
create index if not exists idx_cash_mov_txn   on app.cash_movements(ref_txn_id);

-- =====================================================================
-- Triggers — block edits and back-dating
-- =====================================================================

-- Cash movements: insert-only.
create or replace function app.cash_movements_no_update_delete()
returns trigger language plpgsql as $$
begin
  raise exception 'cash_movements is append-only';
end;
$$;

drop trigger if exists trg_cash_mov_no_update on app.cash_movements;
create trigger trg_cash_mov_no_update
  before update or delete on app.cash_movements
  for each row execute function app.cash_movements_no_update_delete();

-- Force occurred_at = now() and created_by = auth.uid() on insert.
create or replace function app.cash_movements_stamp()
returns trigger language plpgsql as $$
begin
  new.occurred_at := now();          -- vector #25: no backdating
  new.created_at  := now();
  new.created_by  := auth.uid();
  -- The shift must be open and belong to the same user / till must be active.
  if not exists (
    select 1 from app.shifts s
    where s.id = new.shift_id and s.status = 'open'
  ) then
    raise exception 'cash movement requires an OPEN shift (got shift %)', new.shift_id;
  end if;
  return new;
end;
$$;

drop trigger if exists trg_cash_mov_stamp on app.cash_movements;
create trigger trg_cash_mov_stamp
  before insert on app.cash_movements
  for each row execute function app.cash_movements_stamp();

-- Shifts: tightly constrain UPDATE paths. Only specific transitions are
-- allowed and they must come through the SECURITY DEFINER functions
-- below (which set a session GUC the trigger checks for).
create or replace function app.shifts_guard_update()
returns trigger language plpgsql as $$
begin
  if current_setting('app.shift_internal', true) is distinct from 'on' then
    raise exception 'direct UPDATE on app.shifts is not allowed; use app.declare_close / app.finalize_close';
  end if;
  return new;
end;
$$;

drop trigger if exists trg_shifts_guard_update on app.shifts;
create trigger trg_shifts_guard_update
  before update on app.shifts
  for each row execute function app.shifts_guard_update();

create or replace function app.shifts_no_delete()
returns trigger language plpgsql as $$
begin
  raise exception 'shifts cannot be deleted';
end;
$$;

drop trigger if exists trg_shifts_no_delete on app.shifts;
create trigger trg_shifts_no_delete
  before delete on app.shifts
  for each row execute function app.shifts_no_delete();

-- =====================================================================
-- SECURITY DEFINER functions — the only legal way to mutate shifts.
-- =====================================================================

-- Open a new shift on a till for the current user.
create or replace function app.open_shift(
  p_till_id     uuid,
  p_opening_usd numeric,
  p_opening_lbp numeric
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_shop  uuid;
  v_shift uuid;
begin
  if p_opening_usd is null or p_opening_lbp is null then
    raise exception 'opening counts are required';
  end if;
  if p_opening_usd < 0 or p_opening_lbp < 0 then
    raise exception 'opening counts must be non-negative';
  end if;

  select shop_id into v_shop from app.tills where id = p_till_id and is_active;
  if v_shop is null then
    raise exception 'till % not found or inactive', p_till_id;
  end if;

  -- Caller must be a cashier or manager in this shop.
  if not app.has_any_role_in_shop(v_shop, array['cashier','manager']::app.business_role[]) then
    raise exception 'not authorized to open a shift on this till';
  end if;

  -- Reject if any non-closed shift exists on this till.
  if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
    raise exception 'till % already has an active shift; close it first', p_till_id;
  end if;

  insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
    values (p_till_id, v_shop, auth.uid(), auth.uid(), p_opening_usd, p_opening_lbp)
    returning id into v_shift;

  -- Record the opening float as a cash movement for clean ledgers.
  insert into app.cash_movements(shift_id, type, currency, amount, note)
    values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
           (v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');

  perform app.log_auth_event('shift_opened', v_shop, null,
                             jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id));
  return v_shift;
end;
$$;

-- Phase 1 of close: cashier declares the cash count. Expected is NOT
-- revealed until phase 2 (vector #14: blind close).
create or replace function app.declare_close(
  p_shift_id           uuid,
  p_declared_close_usd numeric,
  p_declared_close_lbp numeric
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s record;
begin
  select * into s from app.shifts where id = p_shift_id;
  if s.id is null then raise exception 'shift not found'; end if;
  if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
    raise exception 'only the shift owner or a manager may declare close';
  end if;
  if s.status <> 'open' then
    raise exception 'shift % is not open (status=%)', p_shift_id, s.status;
  end if;
  if p_declared_close_usd is null or p_declared_close_lbp is null
     or p_declared_close_usd < 0  or p_declared_close_lbp < 0 then
    raise exception 'declared counts must be non-negative numbers';
  end if;

  perform set_config('app.shift_internal', 'on', true);
  update app.shifts
    set status              = 'declared',
        declared_at         = now(),
        declared_close_usd  = p_declared_close_usd,
        declared_close_lbp  = p_declared_close_lbp
    where id = p_shift_id;
  perform set_config('app.shift_internal', 'off', true);

  perform app.log_auth_event('shift_declared', s.shop_id, null,
                             jsonb_build_object('shift_id', p_shift_id));
end;
$$;

-- Phase 2 of close: compute expected and variance, lock the shift.
create or replace function app.finalize_close(p_shift_id uuid)
returns table (
  expected_usd numeric,
  expected_lbp numeric,
  variance_usd numeric,
  variance_lbp numeric
)
language plpgsql
security definer
set search_path = app, public
as $$
declare
  s record;
  v_exp_usd numeric;
  v_exp_lbp numeric;
begin
  select * into s from app.shifts where id = p_shift_id;
  if s.id is null then raise exception 'shift not found'; end if;
  if s.status <> 'declared' then
    raise exception 'shift % must be in DECLARED state to finalize (was %)', p_shift_id, s.status;
  end if;
  if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
    raise exception 'only the shift owner or a manager may finalize close';
  end if;

  -- Expected = sum of signed cash movements in each currency.
  -- opening_float rows are already part of cash_movements, so the sum is
  -- the full expected drawer count.
  select
    coalesce(sum(case when currency = 'USD' then amount end), 0),
    coalesce(sum(case when currency = 'LBP' then amount end), 0)
  into v_exp_usd, v_exp_lbp
  from app.cash_movements
  where shift_id = p_shift_id;

  perform set_config('app.shift_internal', 'on', true);
  update app.shifts
    set status             = 'closed',
        closed_at          = now(),
        closed_by          = auth.uid(),
        expected_close_usd = v_exp_usd,
        expected_close_lbp = v_exp_lbp,
        variance_usd       = s.declared_close_usd - v_exp_usd,
        variance_lbp       = s.declared_close_lbp - v_exp_lbp
    where id = p_shift_id;
  perform set_config('app.shift_internal', 'off', true);

  perform app.log_auth_event('shift_closed', s.shop_id, null,
    jsonb_build_object(
      'shift_id', p_shift_id,
      'variance_usd', s.declared_close_usd - v_exp_usd,
      'variance_lbp', s.declared_close_lbp - v_exp_lbp
    ));

  return query
    select v_exp_usd, v_exp_lbp,
           s.declared_close_usd - v_exp_usd,
           s.declared_close_lbp - v_exp_lbp;
end;
$$;

revoke all on function app.open_shift(uuid, numeric, numeric)        from public;
revoke all on function app.declare_close(uuid, numeric, numeric)     from public;
revoke all on function app.finalize_close(uuid)                      from public;
grant execute on function app.open_shift(uuid, numeric, numeric)        to authenticated;
grant execute on function app.declare_close(uuid, numeric, numeric)     to authenticated;
grant execute on function app.finalize_close(uuid)                      to authenticated;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.shifts          enable row level security;
alter table app.cash_movements  enable row level security;
alter table app.shifts          force  row level security;
alter table app.cash_movements  force  row level security;

-- Block direct INSERT/UPDATE on shifts; only the SECURITY DEFINER
-- functions above (which run as the function owner) may write.
revoke insert, update, delete on app.shifts from authenticated;

drop policy if exists shifts_select on app.shifts;
create policy shifts_select on app.shifts
  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[])
  );

-- Cash movements: direct INSERT permitted (with RLS check) so cashiers
-- can record sale_in / payout_out from the txn flow; UPDATE/DELETE are
-- already blocked by triggers.
drop policy if exists cash_mov_select on app.cash_movements;
create policy cash_mov_select on app.cash_movements
  for select to authenticated
  using (
    exists (
      select 1 from app.shifts s
      where s.id = cash_movements.shift_id
        and (
          s.user_id = auth.uid()
          or app.has_any_role_in_shop(s.shop_id, array['owner','manager','auditor']::app.business_role[])
        )
    )
  );

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

grant select on app.shifts         to authenticated;
grant select, insert on app.cash_movements to authenticated;

-- End migration 0002 ----------------------------------------------------
