231 lines
9.4 KiB
PL/PgSQL
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();
|