Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
-- =====================================================================
|
||||
-- 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 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user