Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
-- =====================================================================
|
||||
-- Migration 0019 — Cash-movement sign guard + fix record_cash_drop.
|
||||
--
|
||||
-- Two bugs in 0015 + 0002:
|
||||
--
|
||||
-- (a) record_cash_drop in 0015 inserted into app.cash_movements using
|
||||
-- column names that do not exist (`movement_type`, `external_ref`)
|
||||
-- and an enum value that doesn't exist (`safe_drop`). The real
|
||||
-- schema is `type` / `note` and the enum value is `drop_to_safe`.
|
||||
-- Worse, it inserted the drop amount as POSITIVE, which would make
|
||||
-- `expected_close_usd` go UP when cash physically left the till.
|
||||
--
|
||||
-- (b) app.cash_movements has no constraint that the sign of `amount`
|
||||
-- matches the movement `type`. A buggy or malicious insert with
|
||||
-- type='drop_to_safe' amount=+1000 would silently increase the
|
||||
-- expected drawer balance.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Adds a BEFORE INSERT trigger on app.cash_movements enforcing the
|
||||
-- sign-vs-type rule.
|
||||
-- 2. Replaces app.record_cash_drop with a correct implementation
|
||||
-- using the real columns and a negative sign.
|
||||
--
|
||||
-- Threat-model rows: 7, 14, 22, 25.
|
||||
-- =====================================================================
|
||||
|
||||
-- =====================================================================
|
||||
-- Sign-vs-type guard
|
||||
-- =====================================================================
|
||||
create or replace function app._cash_mov_sign_check()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
-- + cash into till
|
||||
if new.type in ('opening_float','sale_in','fx_swap_in') then
|
||||
if new.amount <= 0 then
|
||||
raise exception 'cash_movements.type=% must have positive amount (got %)',
|
||||
new.type, new.amount;
|
||||
end if;
|
||||
-- - cash out of till
|
||||
elsif new.type in ('payout_out','drop_to_safe','bank_deposit',
|
||||
'expense','fx_swap_out') then
|
||||
if new.amount >= 0 then
|
||||
raise exception 'cash_movements.type=% must have negative amount (got %)',
|
||||
new.type, new.amount;
|
||||
end if;
|
||||
-- 'adjustment' is the only type that may legitimately go either way
|
||||
-- (manager-approved correction). It must still be non-zero (already
|
||||
-- enforced by the table CHECK).
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_cash_mov_sign_check on app.cash_movements;
|
||||
create trigger trg_cash_mov_sign_check
|
||||
before insert on app.cash_movements
|
||||
for each row execute function app._cash_mov_sign_check();
|
||||
|
||||
-- =====================================================================
|
||||
-- Replace record_cash_drop with a correct implementation.
|
||||
-- Drops are negative cash_movement rows of type 'drop_to_safe'.
|
||||
-- =====================================================================
|
||||
create or replace function app.record_cash_drop(
|
||||
p_shift_id uuid,
|
||||
p_drop_usd numeric,
|
||||
p_drop_lbp numeric,
|
||||
p_notes text default null
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare
|
||||
v_shop uuid;
|
||||
v_status app.shift_status;
|
||||
v_user uuid;
|
||||
begin
|
||||
if p_drop_usd is null or p_drop_lbp is null
|
||||
or p_drop_usd < 0 or p_drop_lbp < 0 then
|
||||
raise exception 'drop amounts must be non-negative numbers';
|
||||
end if;
|
||||
if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
|
||||
raise exception 'must drop > 0 in at least one currency';
|
||||
end if;
|
||||
|
||||
select shop_id, status, user_id
|
||||
into v_shop, v_status, v_user
|
||||
from app.shifts
|
||||
where id = p_shift_id;
|
||||
if v_shop is null then
|
||||
raise exception 'shift % not found', p_shift_id;
|
||||
end if;
|
||||
if v_status <> 'open' then
|
||||
raise exception 'shift must be open to record a drop (got %)', v_status;
|
||||
end if;
|
||||
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
|
||||
raise exception 'only the shift owner or a manager may record a drop';
|
||||
end if;
|
||||
|
||||
if p_drop_usd > 0 then
|
||||
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||
values (p_shift_id, 'drop_to_safe', 'USD', -p_drop_usd,
|
||||
coalesce(p_notes, 'mid-day safe drop'));
|
||||
end if;
|
||||
if p_drop_lbp > 0 then
|
||||
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||
values (p_shift_id, 'drop_to_safe', 'LBP', -p_drop_lbp,
|
||||
coalesce(p_notes, 'mid-day safe drop'));
|
||||
end if;
|
||||
|
||||
perform app.log_auth_event('safe_drop_recorded', v_shop, 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;
|
||||
|
||||
-- End migration 0019 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user