Files
OMT-SM/supabase/migrations/0021_fee_schedule.sql
T

231 lines
9.4 KiB
PL/PgSQL

-- =====================================================================
-- 0021_fee_schedule.sql
--
-- Today the cashier types fee_usd / fee_lbp / commission_usd / commission_lbp
-- by hand on every OMT_SEND, OMT_RECEIVE, WU_*, WHISH_SEND, EDL_BILL,
-- recharge and goods sale. There is no server-side anchor for what the
-- fee is *supposed* to be, which means a cashier can:
--
-- * pocket part of the customer's fee by recording a smaller fee
-- than they collected,
-- * record a larger fee than the official sheet to siphon shop
-- commission, then refund the excess to themselves later.
--
-- This migration adds an opt-in per-shop fee schedule:
--
-- app.fee_schedule(shop_id, service_code, currency,
-- min_amount, max_amount,
-- fee_fixed, fee_pct,
-- commission_fixed, commission_pct,
-- tolerance)
--
-- and a deferred constraint trigger that, *only when at least one row
-- exists for the shop+service+currency*, validates the fee/commission
-- on the transaction against the bracket the gross falls into. Shops
-- that don't seed the table keep working exactly as before.
-- =====================================================================
set search_path = app, public;
create table if not exists app.fee_schedule (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null references app.services(code),
currency app.currency_code not null,
-- Inclusive lower bound, exclusive upper bound (use a very large
-- max_amount for the "and above" bracket).
min_amount numeric(18,2) not null check (min_amount >= 0),
max_amount numeric(18,2) not null,
fee_fixed numeric(18,2) not null default 0 check (fee_fixed >= 0),
fee_pct numeric(7,4) not null default 0 check (fee_pct >= 0 and fee_pct <= 100),
commission_fixed numeric(18,2) not null default 0 check (commission_fixed >= 0),
commission_pct numeric(7,4) not null default 0 check (commission_pct >= 0 and commission_pct <= 100),
-- Allowed absolute tolerance between scheduled and recorded fee. Set
-- non-zero for services priced in LBP rounded to nearest 1000.
tolerance numeric(18,2) not null default 0 check (tolerance >= 0),
effective_from timestamptz not null default now(),
effective_to timestamptz,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
check (max_amount > min_amount),
check (effective_to is null or effective_to > effective_from)
);
create index if not exists idx_fee_schedule_lookup
on app.fee_schedule(shop_id, service_code, currency, effective_from desc);
alter table app.fee_schedule enable row level security;
alter table app.fee_schedule force row level security;
-- Owners/managers of the shop can read and edit. Cashiers can read.
drop policy if exists fee_schedule_select on app.fee_schedule;
create policy fee_schedule_select on app.fee_schedule
for select using (
app.has_any_role_in_shop(shop_id,
array['cashier','manager','owner']::app.business_role[])
);
drop policy if exists fee_schedule_write on app.fee_schedule;
create policy fee_schedule_write on app.fee_schedule
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: once published, a row's bracket cannot be
-- mutated; managers must close it (set effective_to) and insert a new
-- one. This preserves a clean audit trail of what fees were in force.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_immutable()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'fee_schedule rows are append-only; close them with effective_to';
end if;
-- Only effective_to may move forward (close a bracket). Everything
-- else must stay put.
if (old.shop_id, old.service_code, old.currency, old.min_amount,
old.max_amount, old.fee_fixed, old.fee_pct,
old.commission_fixed, old.commission_pct, old.tolerance,
old.effective_from)
is distinct from
(new.shop_id, new.service_code, new.currency, new.min_amount,
new.max_amount, new.fee_fixed, new.fee_pct,
new.commission_fixed, new.commission_pct, new.tolerance,
new.effective_from)
then
raise exception 'fee_schedule columns are immutable; close the row and insert a new one';
end if;
if old.effective_to is not null then
raise exception 'fee_schedule row already closed';
end if;
if new.effective_to is null or new.effective_to <= now() - interval '1 minute' then
raise exception 'effective_to must be set to a current/future timestamp to close a bracket';
end if;
return new;
end;
$$;
drop trigger if exists trg_fee_schedule_immutable on app.fee_schedule;
create trigger trg_fee_schedule_immutable
before update or delete on app.fee_schedule
for each row execute function app._fee_schedule_immutable();
-- ---------------------------------------------------------------------
-- Lookup helper: returns the active bracket for a (shop, service,
-- currency, gross). Returns NULL if no schedule applies.
-- ---------------------------------------------------------------------
create or replace function app.compute_scheduled_fee(
p_shop uuid,
p_service text,
p_currency app.currency_code,
p_gross numeric
) returns table (
expected_fee numeric,
expected_commission numeric,
tolerance numeric,
bracket_id uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
select
coalesce(fs.fee_fixed,0) + coalesce(fs.fee_pct,0) / 100.0 * p_gross,
coalesce(fs.commission_fixed,0) + coalesce(fs.commission_pct,0) / 100.0 * p_gross,
fs.tolerance,
fs.id
from app.fee_schedule fs
where fs.shop_id = p_shop
and fs.service_code = p_service
and fs.currency = p_currency
and fs.min_amount <= p_gross
and fs.max_amount > p_gross
and fs.effective_from <= now()
and (fs.effective_to is null or fs.effective_to > now())
order by fs.effective_from desc
limit 1;
$$;
revoke all on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) from public;
grant execute on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) to authenticated;
-- ---------------------------------------------------------------------
-- Constraint trigger: validates fee/commission against the schedule
-- when a matching bracket exists. Runs on INSERT (transactions are
-- append-only). Fired DEFERRED so the txn row is fully populated before
-- we look it up.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
rec_usd record;
rec_lbp record;
diff numeric;
begin
-- Only validate completed money transactions; refunds, voids and
-- non-monetary services bypass.
if new.status <> 'completed' then return null; end if;
if new.service_code in ('REFUND','OPENING_FLOAT','SAFE_DROP','BANK_DEPOSIT')
then return null; end if;
if coalesce(new.gross_usd, 0) > 0 then
select * into rec_usd
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'USD'::app.currency_code, new.gross_usd);
if rec_usd.bracket_id is not null then
diff := abs(coalesce(new.fee_usd,0) - rec_usd.expected_fee);
if diff > rec_usd.tolerance then
raise exception
'fee_usd % deviates from schedule % (tolerance %, bracket %)',
new.fee_usd, rec_usd.expected_fee, rec_usd.tolerance, rec_usd.bracket_id;
end if;
diff := abs(coalesce(new.commission_usd,0) - rec_usd.expected_commission);
if diff > rec_usd.tolerance then
raise exception
'commission_usd % deviates from schedule % (tolerance %, bracket %)',
new.commission_usd, rec_usd.expected_commission, rec_usd.tolerance, rec_usd.bracket_id;
end if;
end if;
end if;
if coalesce(new.gross_lbp, 0) > 0 then
select * into rec_lbp
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'LBP'::app.currency_code, new.gross_lbp);
if rec_lbp.bracket_id is not null then
diff := abs(coalesce(new.fee_lbp,0) - rec_lbp.expected_fee);
if diff > rec_lbp.tolerance then
raise exception
'fee_lbp % deviates from schedule % (tolerance %, bracket %)',
new.fee_lbp, rec_lbp.expected_fee, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
diff := abs(coalesce(new.commission_lbp,0) - rec_lbp.expected_commission);
if diff > rec_lbp.tolerance then
raise exception
'commission_lbp % deviates from schedule % (tolerance %, bracket %)',
new.commission_lbp, rec_lbp.expected_commission, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_fee_schedule_check on app.transactions;
create constraint trigger trg_fee_schedule_check
after insert on app.transactions
deferrable initially deferred
for each row execute function app._fee_schedule_check();