Files
OMT-SM/supabase/migrations/0002_shifts_and_cash.sql
T

392 lines
15 KiB
PL/PgSQL

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