312 lines
12 KiB
PL/PgSQL
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;
|