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