-- =====================================================================
-- 0025_safe_and_bank_ledger.sql
--
-- The book has two cash buckets that already appear as cash_movement
-- types but have no first-class ledger:
--    drop_to_safe   — cashier moves notes from the till to the shop safe
--    bank_deposit   — owner / manager takes notes from the safe to the bank
--
-- Without a paired ledger the safe balance is essentially "trust me",
-- and a manager can quietly skim from the safe without leaving any
-- audit trail. record_cash_drop (fixed in 0019) only debits the till
-- side; the safe side is implicit.
--
-- This migration:
--   * adds `app.safes` (one logical safe per shop) and
--     `app.safe_movements` (append-only ledger),
--   * extends `record_cash_drop` to also CREDIT the safe in the same
--     transaction (caller still uses the same signature),
--   * adds `app.record_bank_deposit(shop, currency, amount, bank_ref,
--     deposit_slip_url, notes)` that DEBITS the safe and creates the
--     `bank_deposit` cash_movements row + an audit row in
--     `app.bank_deposits`,
--   * exposes a view `v_safe_balance` for the owner dashboard.
-- =====================================================================

set search_path = app, public;

create table if not exists app.safes (
  id         uuid primary key default gen_random_uuid(),
  shop_id    uuid not null references app.shops(id) on delete restrict,
  name       text not null default 'Main Safe',
  is_active  boolean not null default true,
  created_at timestamptz not null default now(),
  unique(shop_id, name)
);

-- One default safe per shop (idempotent).
insert into app.safes(shop_id, name)
  select s.id, 'Main Safe' from app.shops s
  on conflict do nothing;

-- Auto-create a Main Safe whenever a new shop is added.
create or replace function app._auto_create_safe()
returns trigger language plpgsql
security definer
set search_path = app, public
as $$
begin
  insert into app.safes(shop_id, name) values (new.id, 'Main Safe')
    on conflict do nothing;
  return new;
end;
$$;
drop trigger if exists trg_auto_create_safe on app.shops;
create trigger trg_auto_create_safe
  after insert on app.shops
  for each row execute function app._auto_create_safe();

-- =====================================================================
-- Append-only safe ledger
-- =====================================================================
create table if not exists app.safe_movements (
  id            uuid primary key default gen_random_uuid(),
  safe_id       uuid not null references app.safes(id) on delete restrict,
  occurred_at   timestamptz not null default now(),
  -- + cash IN to safe (drop from till), - cash OUT (deposit to bank,
  -- expense from safe).
  amount        numeric(18,2) not null check (amount <> 0),
  currency      app.currency_code not null,
  -- Optional links to the originating events.
  ref_shift_id  uuid references app.shifts(id),
  ref_txn_id    uuid references app.transactions(id),
  ref_cash_movement_id uuid references app.cash_movements(id),
  reason        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_safe_mov_safe   on app.safe_movements(safe_id, occurred_at);
create index if not exists idx_safe_mov_shift  on app.safe_movements(ref_shift_id);

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

-- Maintain a per-safe per-currency cached balance.
create table if not exists app.safe_balances (
  safe_id    uuid not null references app.safes(id) on delete cascade,
  currency   app.currency_code not null,
  balance    numeric(20,2) not null default 0,
  updated_at timestamptz not null default now(),
  primary key (safe_id, currency)
);

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

-- RLS: cashiers can read movements for their shop's safe; managers/owners
-- can write via the SECURITY DEFINER functions below (no direct DML).
alter table app.safe_movements enable row level security;
alter table app.safe_movements force  row level security;
alter table app.safe_balances  enable row level security;
alter table app.safe_balances  force  row level security;

drop policy if exists safe_mov_select on app.safe_movements;
create policy safe_mov_select on app.safe_movements
  for select using (
    exists (
      select 1 from app.safes s
       where s.id = safe_id
         and app.has_any_role_in_shop(s.shop_id,
              array['cashier','manager','owner']::app.business_role[])
    )
  );

drop policy if exists safe_bal_select on app.safe_balances;
create policy safe_bal_select on app.safe_balances
  for select using (
    exists (
      select 1 from app.safes s
       where s.id = safe_id
         and app.has_any_role_in_shop(s.shop_id,
              array['cashier','manager','owner']::app.business_role[])
    )
  );

-- =====================================================================
-- Bank deposits (paper trail for cash leaving the safe to the bank)
-- =====================================================================
create table if not exists app.bank_deposits (
  id              uuid primary key default gen_random_uuid(),
  shop_id         uuid not null references app.shops(id),
  safe_id         uuid not null references app.safes(id),
  shift_id        uuid references app.shifts(id),    -- optional link
  amount          numeric(18,2) not null check (amount > 0),
  currency        app.currency_code not null,
  bank_ref        text,
  deposit_slip_url text,
  notes           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_bank_dep_shop on app.bank_deposits(shop_id, created_at desc);

-- =====================================================================
-- Re-define record_cash_drop to also credit the safe
-- =====================================================================
drop function if exists app.record_cash_drop(uuid, numeric, numeric, text);
create or replace function app.record_cash_drop(
  p_shift_id uuid,
  p_drop_usd numeric,
  p_drop_lbp numeric,
  p_notes    text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
  s        app.shifts%rowtype;
  v_safe   uuid;
  v_cash_id uuid;
begin
  if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
    raise exception 'drop must be > 0 in at least one currency';
  end if;
  if coalesce(p_drop_usd,0) < 0 or coalesce(p_drop_lbp,0) < 0 then
    raise exception 'drop amounts must be positive (the function negates)';
  end if;

  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 <> 'open' then
    raise exception 'cannot drop on a closed/declared shift';
  end if;

  -- Find the shop's main safe.
  select id into v_safe from app.safes
   where shop_id = s.shop_id and is_active limit 1;
  if v_safe is null then
    raise exception 'no active safe for shop %', s.shop_id;
  end if;

  if coalesce(p_drop_usd,0) > 0 then
    insert into app.cash_movements(shift_id, type, currency, amount, note)
      values (p_shift_id, 'drop_to_safe'::app.cash_movement_type,
              'USD'::app.currency_code, -p_drop_usd, p_notes)
      returning id into v_cash_id;
    insert into app.safe_movements(
      safe_id, currency, amount, ref_shift_id, ref_cash_movement_id, reason
    ) values (
      v_safe, 'USD'::app.currency_code, p_drop_usd,
      p_shift_id, v_cash_id, coalesce(p_notes, 'till drop')
    );
  end if;

  if coalesce(p_drop_lbp,0) > 0 then
    insert into app.cash_movements(shift_id, type, currency, amount, note)
      values (p_shift_id, 'drop_to_safe'::app.cash_movement_type,
              'LBP'::app.currency_code, -p_drop_lbp, p_notes)
      returning id into v_cash_id;
    insert into app.safe_movements(
      safe_id, currency, amount, ref_shift_id, ref_cash_movement_id, reason
    ) values (
      v_safe, 'LBP'::app.currency_code, p_drop_lbp,
      p_shift_id, v_cash_id, coalesce(p_notes, 'till drop')
    );
  end if;

  perform app.log_auth_event('cash_drop', s.shop_id, null,
    jsonb_build_object('shift_id', p_shift_id,
                       'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;

-- =====================================================================
-- record_bank_deposit: take cash out of the safe to the bank.
-- Manager-only, leaves a slip-photo URL for evidence.
-- =====================================================================
create or replace function app.record_bank_deposit(
  p_shop          uuid,
  p_currency      app.currency_code,
  p_amount        numeric,
  p_bank_ref      text,
  p_deposit_slip_url text,
  p_notes         text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
  v_safe uuid;
  v_dep  uuid;
begin
  if p_amount is null or p_amount <= 0 then
    raise exception 'deposit amount must be > 0';
  end if;
  if not app.has_role_in_shop(p_shop, 'manager')
     and not app.has_role_in_shop(p_shop, 'owner')
  then
    raise exception 'manager or owner role required';
  end if;

  select id into v_safe from app.safes
   where shop_id = p_shop and is_active limit 1;
  if v_safe is null then raise exception 'no active safe for shop'; end if;

  insert into app.bank_deposits(
    shop_id, safe_id, amount, currency, bank_ref, deposit_slip_url, notes
  ) values (
    p_shop, v_safe, p_amount, p_currency, p_bank_ref, p_deposit_slip_url, p_notes
  ) returning id into v_dep;

  -- Debit the safe (will fail if insufficient balance thanks to the
  -- _safe_balance_apply trigger).
  insert into app.safe_movements(
    safe_id, currency, amount, reason
  ) values (
    v_safe, p_currency, -p_amount,
    'bank deposit ' || coalesce(p_bank_ref, v_dep::text)
  );

  perform app.log_auth_event('bank_deposit', p_shop, null,
    jsonb_build_object('deposit_id', v_dep,
                       'amount', p_amount, 'currency', p_currency,
                       'bank_ref', p_bank_ref));
  return v_dep;
end;
$$;
revoke all on function app.record_bank_deposit(uuid, app.currency_code, numeric, text, text, text) from public;
grant execute on function app.record_bank_deposit(uuid, app.currency_code, numeric, text, text, text) to authenticated;

-- =====================================================================
-- Owner-friendly view of safe balances joined to shop names.
-- =====================================================================
create or replace view app.v_safe_balance as
  select s.shop_id,
         sh.name        as shop_name,
         s.id           as safe_id,
         s.name         as safe_name,
         b.currency,
         coalesce(b.balance, 0) as balance,
         b.updated_at
    from app.safes s
    join app.shops sh on sh.id = s.shop_id
    left join app.safe_balances b on b.safe_id = s.id;

grant select on app.v_safe_balance to authenticated;
