Files
OMT-SM/supabase/migrations/0025_safe_and_bank_ledger.sql
T

312 lines
12 KiB
PL/PgSQL

-- =====================================================================
-- 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;