392 lines
15 KiB
PL/PgSQL
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 ----------------------------------------------------
|