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