Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
-- =====================================================================
|
||||
-- 0023_fx_rates_and_swap.sql
|
||||
--
|
||||
-- Two related holes around foreign exchange:
|
||||
--
|
||||
-- (1) The cashier types an arbitrary `fx_rate_used` on every USD/LBP
|
||||
-- transaction. Nothing on the server compares it to the daily
|
||||
-- posted rate. A cashier can rate a $100 sale at 1 USD = 90,000 LBP
|
||||
-- while the till uses 1 USD = 89,500 LBP and pocket the spread.
|
||||
--
|
||||
-- (2) `cash_movement_type` has 'fx_swap_in' and 'fx_swap_out' but no
|
||||
-- function posts them as a matched pair. A cashier swapping $100
|
||||
-- out of the till for 8.95M LBP today does it manually with two
|
||||
-- uncoupled cash_movements rows; the sign-guard added in 0019
|
||||
-- catches gross sign mistakes but not amount mismatches.
|
||||
--
|
||||
-- This migration:
|
||||
-- * adds `app.fx_rates(shop_id, effective_from, usd_to_lbp_rate,
|
||||
-- tolerance_pct)` — append-only history of the shop's posted rate.
|
||||
-- * adds `app.compute_fx_window(shop, effective)` returning the
|
||||
-- accepted band [low, high] for the currently-active rate.
|
||||
-- * adds a constraint trigger on `app.transactions` that, only when
|
||||
-- a rate is published for the shop, enforces fx_rate_used falls
|
||||
-- within the band whenever both gross_usd and gross_lbp are non-zero
|
||||
-- (genuine cross-currency txn) OR for explicit FX swaps.
|
||||
-- * adds `app.record_fx_swap(shop, till, p_usd_amount, p_lbp_amount,
|
||||
-- p_fx_rate)` which posts both legs atomically and refuses the call
|
||||
-- unless |p_usd_amount * fx_rate - p_lbp_amount| <= 1 LBP.
|
||||
--
|
||||
-- Shops that don't seed `fx_rates` keep working unchanged.
|
||||
-- =====================================================================
|
||||
|
||||
set search_path = app, public;
|
||||
|
||||
create table if not exists app.fx_rates (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||
effective_from timestamptz not null default now(),
|
||||
effective_to timestamptz,
|
||||
-- Number of LBP per 1 USD (e.g. 89500).
|
||||
usd_to_lbp_rate numeric(14,2) not null check (usd_to_lbp_rate > 0),
|
||||
-- Allowed deviation either side of the posted rate, as a percent
|
||||
-- (e.g. 1.0 = ±1%). Defaults to 0.5%.
|
||||
tolerance_pct numeric(6,3) not null default 0.5
|
||||
check (tolerance_pct >= 0 and tolerance_pct <= 25),
|
||||
note text,
|
||||
created_at timestamptz not null default now(),
|
||||
created_by uuid not null references auth.users(id) default auth.uid(),
|
||||
check (effective_to is null or effective_to > effective_from)
|
||||
);
|
||||
|
||||
create index if not exists idx_fx_rates_lookup
|
||||
on app.fx_rates(shop_id, effective_from desc);
|
||||
|
||||
alter table app.fx_rates enable row level security;
|
||||
alter table app.fx_rates force row level security;
|
||||
|
||||
drop policy if exists fx_rates_select on app.fx_rates;
|
||||
create policy fx_rates_select on app.fx_rates
|
||||
for select using (
|
||||
app.has_any_role_in_shop(shop_id,
|
||||
array['cashier','manager','owner']::app.business_role[])
|
||||
);
|
||||
|
||||
drop policy if exists fx_rates_write on app.fx_rates;
|
||||
create policy fx_rates_write on app.fx_rates
|
||||
for all using (
|
||||
app.has_any_role_in_shop(shop_id,
|
||||
array['manager','owner']::app.business_role[])
|
||||
) with check (
|
||||
app.has_any_role_in_shop(shop_id,
|
||||
array['manager','owner']::app.business_role[])
|
||||
);
|
||||
|
||||
-- Append-only on history: only effective_to may be moved forward. Same
|
||||
-- pattern as fee_schedule in 0021.
|
||||
create or replace function app._fx_rates_immutable()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'fx_rates is append-only';
|
||||
end if;
|
||||
if (old.shop_id, old.usd_to_lbp_rate, old.tolerance_pct, old.effective_from)
|
||||
is distinct from
|
||||
(new.shop_id, new.usd_to_lbp_rate, new.tolerance_pct, new.effective_from)
|
||||
then
|
||||
raise exception 'fx_rates columns are immutable; close the row and insert a new one';
|
||||
end if;
|
||||
if old.effective_to is not null then
|
||||
raise exception 'fx_rates row already closed';
|
||||
end if;
|
||||
if new.effective_to is null then
|
||||
raise exception 'effective_to must be set to close an fx_rates row';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
drop trigger if exists trg_fx_rates_immutable on app.fx_rates;
|
||||
create trigger trg_fx_rates_immutable
|
||||
before update or delete on app.fx_rates
|
||||
for each row execute function app._fx_rates_immutable();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Lookup helper: return the active rate band for a shop right now.
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function app.current_fx_band(p_shop uuid)
|
||||
returns table (
|
||||
rate numeric,
|
||||
band_low numeric,
|
||||
band_high numeric,
|
||||
tolerance_pct numeric,
|
||||
rate_id uuid
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
select
|
||||
f.usd_to_lbp_rate,
|
||||
f.usd_to_lbp_rate * (1 - f.tolerance_pct / 100.0),
|
||||
f.usd_to_lbp_rate * (1 + f.tolerance_pct / 100.0),
|
||||
f.tolerance_pct,
|
||||
f.id
|
||||
from app.fx_rates f
|
||||
where f.shop_id = p_shop
|
||||
and f.effective_from <= now()
|
||||
and (f.effective_to is null or f.effective_to > now())
|
||||
order by f.effective_from desc
|
||||
limit 1;
|
||||
$$;
|
||||
revoke all on function app.current_fx_band(uuid) from public;
|
||||
grant execute on function app.current_fx_band(uuid) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Constraint trigger on app.transactions:
|
||||
-- when a posted rate exists for the shop, fx_rate_used must lie within
|
||||
-- the band whenever the txn is a genuine USD/LBP cross-currency event.
|
||||
-- ---------------------------------------------------------------------
|
||||
create or replace function app._txn_fx_rate_check()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare
|
||||
band record;
|
||||
begin
|
||||
-- Skip non-money / void rows.
|
||||
if new.status <> 'completed' then return null; end if;
|
||||
if coalesce(new.fx_rate_used, 0) = 0 then return null; end if;
|
||||
|
||||
-- Only police txns that actually mix the two currencies, or where
|
||||
-- the cashier deliberately recorded an fx_rate (e.g. payment in USD,
|
||||
-- gross in LBP).
|
||||
if not (coalesce(new.gross_usd,0) <> 0 and coalesce(new.gross_lbp,0) <> 0)
|
||||
and new.service_code <> 'FX_SWAP'
|
||||
then
|
||||
-- Some single-currency txns also store the day's rate for
|
||||
-- reporting; still validate it against the band so a wildly wrong
|
||||
-- value can't slip through.
|
||||
null;
|
||||
end if;
|
||||
|
||||
select * into band from app.current_fx_band(new.shop_id);
|
||||
if band.rate_id is null then
|
||||
return null; -- shop hasn't published a rate yet
|
||||
end if;
|
||||
|
||||
if new.fx_rate_used < band.band_low or new.fx_rate_used > band.band_high then
|
||||
raise exception
|
||||
'fx_rate_used % outside posted band [%, %] (rate %, tolerance % %%)',
|
||||
new.fx_rate_used, band.band_low, band.band_high,
|
||||
band.rate, band.tolerance_pct;
|
||||
end if;
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
drop trigger if exists trg_txn_fx_rate_check on app.transactions;
|
||||
create constraint trigger trg_txn_fx_rate_check
|
||||
after insert on app.transactions
|
||||
deferrable initially deferred
|
||||
for each row execute function app._txn_fx_rate_check();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- record_fx_swap: post the two cash_movements legs atomically.
|
||||
-- direction:
|
||||
-- p_usd_out > 0 means USD leaves the till and LBP comes in
|
||||
-- -> fx_swap_out USD, fx_swap_in LBP
|
||||
-- p_usd_out < 0 means USD comes into the till and LBP leaves
|
||||
-- -> fx_swap_in USD, fx_swap_out LBP
|
||||
-- The amounts on both sides must agree to within 1 LBP at p_fx_rate,
|
||||
-- and p_fx_rate must lie within the posted band (when one exists).
|
||||
-- ---------------------------------------------------------------------
|
||||
do $$ begin
|
||||
-- Add FX_SWAP service code if not already present, so the txn header
|
||||
-- has a real service to attach to (the recorded txn carries no
|
||||
-- product detail row).
|
||||
if not exists (select 1 from app.services where code = 'FX_SWAP') then
|
||||
insert into app.services(code, name, category)
|
||||
values ('FX_SWAP', 'Currency Exchange', 'cash_ops');
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create or replace function app.record_fx_swap(
|
||||
p_shop uuid,
|
||||
p_till uuid,
|
||||
p_usd_out numeric, -- + USD leaves till, - USD enters till
|
||||
p_lbp_in numeric, -- + LBP enters till when usd_out>0, must be opposite sign
|
||||
p_fx_rate numeric,
|
||||
p_notes text default null
|
||||
) returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
as $$
|
||||
declare
|
||||
v_shift uuid;
|
||||
v_diff numeric;
|
||||
v_txn uuid;
|
||||
band record;
|
||||
begin
|
||||
if p_usd_out is null or p_lbp_in is null or p_fx_rate is null or p_fx_rate <= 0 then
|
||||
raise exception 'usd_out, lbp_in and positive fx_rate are required';
|
||||
end if;
|
||||
if p_usd_out = 0 then
|
||||
raise exception 'usd_out cannot be 0';
|
||||
end if;
|
||||
if sign(p_usd_out) = sign(p_lbp_in) then
|
||||
raise exception 'usd_out and lbp_in must have opposite signs (one in, one out)';
|
||||
end if;
|
||||
|
||||
-- Sanity: |usd_out| * rate must equal |lbp_in| within 1 LBP.
|
||||
v_diff := abs(abs(p_usd_out) * p_fx_rate - abs(p_lbp_in));
|
||||
if v_diff > 1 then
|
||||
raise exception
|
||||
'fx swap mismatch: |usd_out|*rate = % but |lbp_in| = % (diff %)',
|
||||
abs(p_usd_out) * p_fx_rate, abs(p_lbp_in), v_diff;
|
||||
end if;
|
||||
|
||||
-- Rate band (only enforced when a rate is published).
|
||||
select * into band from app.current_fx_band(p_shop);
|
||||
if band.rate_id is not null
|
||||
and (p_fx_rate < band.band_low or p_fx_rate > band.band_high)
|
||||
then
|
||||
raise exception
|
||||
'fx_rate % outside posted band [%, %] (rate %, tolerance % %%)',
|
||||
p_fx_rate, band.band_low, band.band_high, band.rate, band.tolerance_pct;
|
||||
end if;
|
||||
|
||||
-- Caller must have an open shift on this till.
|
||||
select id into v_shift from app.shifts
|
||||
where till_id = p_till and shop_id = p_shop
|
||||
and user_id = auth.uid() and status = 'open'
|
||||
order by opened_at desc limit 1;
|
||||
if v_shift is null then
|
||||
raise exception 'no open shift for this till';
|
||||
end if;
|
||||
|
||||
-- Create a header txn (gross_usd/lbp = 0 — money does not enter or
|
||||
-- leave the shop, just changes currency). The fx_rate is recorded.
|
||||
v_txn := app._insert_txn(p_shop, p_till, 'FX_SWAP', 'cash_usd'::app.payment_method,
|
||||
0, 0, 0, 0, 0, 0, p_fx_rate,
|
||||
null, null, null, null, null, p_notes);
|
||||
|
||||
-- USD leg.
|
||||
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||
values (
|
||||
v_shift,
|
||||
case when p_usd_out > 0
|
||||
then 'fx_swap_out'::app.cash_movement_type
|
||||
else 'fx_swap_in'::app.cash_movement_type end,
|
||||
'USD'::app.currency_code,
|
||||
-p_usd_out, -- p_usd_out is the OUTflow amount
|
||||
v_txn,
|
||||
'fx swap'
|
||||
);
|
||||
|
||||
-- LBP leg.
|
||||
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||
values (
|
||||
v_shift,
|
||||
case when p_lbp_in > 0
|
||||
then 'fx_swap_in'::app.cash_movement_type
|
||||
else 'fx_swap_out'::app.cash_movement_type end,
|
||||
'LBP'::app.currency_code,
|
||||
p_lbp_in,
|
||||
v_txn,
|
||||
'fx swap'
|
||||
);
|
||||
|
||||
return v_txn;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) from public;
|
||||
grant execute on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) to authenticated;
|
||||
Reference in New Issue
Block a user