Files
OMT-SM/supabase/migrations/0008_refunds_and_overrides.sql
T

431 lines
18 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =====================================================================
-- Migration 0008 — Refunds, price overrides, void hardening
-- (roadmap Step 9).
--
-- Voids already exist (0003). This migration adds:
-- * Refunds as their own ledger row, never as a reverse-edit of the
-- original (vector #11).
-- * Price overrides on goods sales: only manager + PIN, capped at
-- a per-shop `max_discount_pct`, fully audited (vector #12).
-- * Void/refund/override summary views per cashier and per
-- (cashier, manager) pair to expose collusion (vector #18).
--
-- Threat-model rows addressed: 11, 12, 18.
-- =====================================================================
-- =====================================================================
-- Refunds
-- =====================================================================
-- A refund is recorded as a transaction with service_code 'REFUND'
-- linked back to the original txn via app.refunds. Money signs are kept
-- positive on the row; cash flows are negative for the shop and are
-- reflected via paired cash_movements / float_movements just like sales.
insert into app.services(code, name, category) values
('REFUND', 'Customer Refund', 'refund')
on conflict (code) do nothing;
create table if not exists app.refunds (
id uuid primary key default gen_random_uuid(),
refund_txn_id uuid not null references app.transactions(id) on delete restrict,
original_txn_id uuid not null references app.transactions(id) on delete restrict,
reason text not null,
manager_approved_by uuid not null references auth.users(id),
amount_usd numeric(14,2) not null default 0 check (amount_usd >= 0),
amount_lbp numeric(18,0) not null default 0 check (amount_lbp >= 0),
created_at timestamptz not null default now(),
constraint refunds_no_self check (refund_txn_id <> original_txn_id),
constraint refunds_unique_refund_txn unique (refund_txn_id)
);
create index if not exists idx_refunds_original on app.refunds(original_txn_id);
create or replace function app._refunds_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'refunds is append-only'; end;
$$;
drop trigger if exists trg_refunds_freeze on app.refunds;
create trigger trg_refunds_freeze before update or delete on app.refunds
for each row execute function app._refunds_no_update_delete();
-- The single legal way to issue a refund. Enforces manager role + PIN,
-- amount ≤ original (minus any prior refunds), original is completed,
-- and creates the refund txn + linkage atomically.
create or replace function app.issue_refund(
p_original_txn uuid,
p_amount_usd numeric,
p_amount_lbp numeric,
p_reason text,
p_manager_pin text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
o app.transactions%rowtype;
s app.shifts%rowtype;
prior_usd numeric := 0;
prior_lbp numeric := 0;
refund_id uuid;
refund_txn uuid;
begin
if p_amount_usd is null or p_amount_lbp is null
or p_amount_usd < 0 or p_amount_lbp < 0
or (p_amount_usd = 0 and p_amount_lbp = 0) then
raise exception 'refund amount must be >= 0 and at least one currency > 0';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into o from app.transactions where id = p_original_txn;
if o.id is null then raise exception 'original txn not found'; end if;
if o.status <> 'completed' then
raise exception 'cannot refund a % transaction', o.status;
end if;
-- Caller must be a manager in the same shop and prove it via PIN.
if not app.has_role_in_shop(o.shop_id, 'manager') then
raise exception 'manager role required to issue a refund';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
-- Refund must be issued on the manager's currently open shift in
-- this shop (so the cash leaves the right till).
select * into s from app.shifts
where shop_id = o.shop_id and status = 'open' and user_id = auth.uid()
limit 1;
if s.id is null then
raise exception 'manager has no open shift in shop % to issue the refund from', o.shop_id;
end if;
-- Prior refunds against this original.
select coalesce(sum(amount_usd),0), coalesce(sum(amount_lbp),0)
into prior_usd, prior_lbp
from app.refunds where original_txn_id = p_original_txn;
if (prior_usd + p_amount_usd) > o.gross_usd then
raise exception 'refund USD exceeds remaining refundable amount (% > %)',
prior_usd + p_amount_usd, o.gross_usd;
end if;
if (prior_lbp + p_amount_lbp) > o.gross_lbp then
raise exception 'refund LBP exceeds remaining refundable amount (% > %)',
prior_lbp + p_amount_lbp, o.gross_lbp;
end if;
-- Create the refund transaction. The standard txn triggers (server
-- stamping, hash chain, sequence) all apply.
insert into app.transactions(
shift_id, shop_id, till_id, user_id, service_code,
gross_usd, gross_lbp, fee_usd, fee_lbp,
payment_method, notes
) values (
s.id, o.shop_id, s.till_id, auth.uid(), 'REFUND',
p_amount_usd, p_amount_lbp, 0, 0,
o.payment_method, 'refund of ' || o.id::text || ' — ' || p_reason
) returning id into refund_txn;
insert into app.refunds(refund_txn_id, original_txn_id, reason,
manager_approved_by, amount_usd, amount_lbp)
values (refund_txn, p_original_txn, p_reason, auth.uid(),
p_amount_usd, p_amount_lbp)
returning id into refund_id;
-- Cash leaves the till (negative cash_movements). Currency split.
if p_amount_usd > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (s.id, 'payout_out', 'USD', -p_amount_usd, refund_txn, 'refund');
end if;
if p_amount_lbp > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (s.id, 'payout_out', 'LBP', -p_amount_lbp, refund_txn, 'refund');
end if;
perform app.log_auth_event('refund_issued', o.shop_id, null,
jsonb_build_object('original', p_original_txn, 'refund_txn', refund_txn,
'amount_usd', p_amount_usd, 'amount_lbp', p_amount_lbp));
return refund_txn;
end;
$$;
revoke all on function app.issue_refund(uuid, numeric, numeric, text, text) from public;
grant execute on function app.issue_refund(uuid, numeric, numeric, text, text) to authenticated;
-- The REFUND service does not need a child detail row; teach the
-- detail-required check to skip it.
create or replace function app.txn_require_detail()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
if new.status <> 'completed' then return null; end if;
case new.service_code
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
when 'REFUND' then select exists(select 1 from app.refunds where refund_txn_id = new.id) into ok;
else ok := true;
end case;
if not ok then
raise exception 'transaction % (service %) is missing its detail/refund row',
new.id, new.service_code;
end if;
return null;
end;
$$;
-- =====================================================================
-- Price overrides on goods sales
-- =====================================================================
-- Per-shop policy: maximum discount % a manager can authorize without
-- escalating to owner.
create table if not exists app.shop_pricing_policy (
shop_id uuid primary key references app.shops(id) on delete cascade,
max_discount_pct numeric(5,2) not null default 10.00 check (max_discount_pct between 0 and 50),
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id)
);
-- Append-only audit table for every override.
create table if not exists app.price_overrides (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
sku text not null references app.items(sku),
list_price_usd numeric(14,2) not null check (list_price_usd > 0),
sold_price_usd numeric(14,2) not null check (sold_price_usd >= 0),
discount_pct numeric(6,2) not null,
reason text not null,
approved_by uuid not null references auth.users(id),
approver_role app.business_role not null,
created_at timestamptz not null default now(),
constraint price_override_unique_per_txn_sku unique (txn_id, sku)
);
create index if not exists idx_price_ovr_txn on app.price_overrides(txn_id);
create or replace function app._price_overrides_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'price_overrides is append-only'; end;
$$;
drop trigger if exists trg_price_ovr_freeze on app.price_overrides;
create trigger trg_price_ovr_freeze before update or delete on app.price_overrides
for each row execute function app._price_overrides_no_update_delete();
-- Definer function: the only legal way to authorize a discount.
-- Returns the approved sold_price; caller passes it into the goods
-- sale flow.
create or replace function app.authorize_price_override(
p_txn_id uuid,
p_sku text,
p_sold_price numeric,
p_reason text,
p_manager_pin text
) returns numeric
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
i app.items%rowtype;
pol app.shop_pricing_policy%rowtype;
pct numeric;
role_used app.business_role;
begin
if p_sold_price is null or p_sold_price < 0 then
raise exception 'sold price must be >= 0';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
if t.status <> 'completed' then
raise exception 'cannot override price on a % transaction', t.status;
end if;
select * into i from app.items where sku = p_sku;
if i.sku is null then raise exception 'sku not found'; end if;
if p_sold_price > i.price_usd then
raise exception 'sold price > list price; not an override';
end if;
pct := round(((i.price_usd - p_sold_price) / nullif(i.price_usd,0)) * 100.0, 2);
-- Caller must be manager or owner in this shop AND give a valid PIN.
if app.has_role_in_shop(t.shop_id, 'owner') then
role_used := 'owner';
elsif app.has_role_in_shop(t.shop_id, 'manager') then
role_used := 'manager';
else
raise exception 'manager or owner role required to override price';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid PIN';
end if;
-- Check shop policy ceiling for managers. Owners can go beyond.
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
if not found then
insert into app.shop_pricing_policy(shop_id) values (t.shop_id)
on conflict (shop_id) do nothing;
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
end if;
if role_used = 'manager' and pct > pol.max_discount_pct then
raise exception 'discount % %% exceeds shop ceiling % %% (owner approval needed)',
pct, pol.max_discount_pct;
end if;
insert into app.price_overrides(
txn_id, sku, list_price_usd, sold_price_usd, discount_pct,
reason, approved_by, approver_role
) values (
p_txn_id, p_sku, i.price_usd, p_sold_price, pct,
p_reason, auth.uid(), role_used
);
perform app.log_auth_event('price_override', t.shop_id, null,
jsonb_build_object('txn', p_txn_id, 'sku', p_sku, 'pct', pct,
'role', role_used));
return p_sold_price;
end;
$$;
revoke all on function app.authorize_price_override(uuid, text, numeric, text, text) from public;
grant execute on function app.authorize_price_override(uuid, text, numeric, text, text) to authenticated;
-- A goods_sale_details row priced below list price MUST have a matching
-- price_overrides row (deferred so the override can be inserted in the
-- same transaction).
create or replace function app._goods_sale_require_override_if_discounted()
returns trigger language plpgsql as $$
declare i app.items%rowtype;
has_ovr boolean;
begin
select * into i from app.items where sku = new.sku;
if i.sku is null then return null; end if; -- FK will catch it
if new.unit_price_usd < i.price_usd then
select exists(
select 1 from app.price_overrides
where txn_id = new.txn_id and sku = new.sku
and sold_price_usd = new.unit_price_usd
) into has_ovr;
if not has_ovr then
raise exception
'goods sale of % below list price (% < %) requires an authorized price override',
new.sku, new.unit_price_usd, i.price_usd;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_override on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_override
after insert on app.goods_sale_details
deferrable initially deferred
for each row execute function app._goods_sale_require_override_if_discounted();
-- =====================================================================
-- Reporting views — collusion / abuse signals (vector #18)
-- =====================================================================
-- Daily voids per cashier
create or replace view app.v_voids_by_cashier_day as
select t.shop_id,
(t.occurred_at at time zone 'UTC')::date as day,
t.user_id as cashier_id,
count(*) as void_count,
sum(t.gross_usd) as voided_usd,
sum(t.gross_lbp) as voided_lbp
from app.transactions t
where t.status = 'voided'
group by 1,2,3;
-- Daily refunds per cashier (the cashier of the original txn)
create or replace view app.v_refunds_by_original_cashier_day as
select o.shop_id,
(r.created_at at time zone 'UTC')::date as day,
o.user_id as original_cashier_id,
r.manager_approved_by as approving_manager_id,
count(*) as refund_count,
sum(r.amount_usd) as refunded_usd,
sum(r.amount_lbp) as refunded_lbp
from app.refunds r
join app.transactions o on o.id = r.original_txn_id
group by 1,2,3,4;
-- Cashiermanager pairs with high void+refund volume (collusion signal)
create or replace view app.v_void_refund_pairs as
select t.shop_id,
t.user_id as cashier_id,
t.void_approved_by as manager_id,
date_trunc('week', t.voided_at) as week_bucket,
count(*) as void_count,
sum(t.gross_usd) as voided_usd
from app.transactions t
where t.status = 'voided' and t.void_approved_by is not null
group by 1,2,3,4
having count(*) >= 5;
-- Price-override volume by approver
create or replace view app.v_overrides_by_approver_day as
select t.shop_id,
(po.created_at at time zone 'UTC')::date as day,
po.approved_by,
po.approver_role,
count(*) as override_count,
sum(po.list_price_usd - po.sold_price_usd) as discount_total_usd,
avg(po.discount_pct) as avg_discount_pct
from app.price_overrides po
join app.transactions t on t.id = po.txn_id
group by 1,2,3,4;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.refunds enable row level security;
alter table app.shop_pricing_policy enable row level security;
alter table app.price_overrides enable row level security;
alter table app.refunds force row level security;
alter table app.shop_pricing_policy force row level security;
alter table app.price_overrides force row level security;
revoke insert, update, delete on app.refunds from authenticated;
revoke insert, update, delete on app.price_overrides from authenticated;
revoke insert, update, delete on app.shop_pricing_policy from authenticated;
drop policy if exists refunds_select on app.refunds;
create policy refunds_select on app.refunds
for select to authenticated
using (app._can_see_txn(refund_txn_id));
grant select on app.refunds to authenticated;
drop policy if exists pricing_policy_select on app.shop_pricing_policy;
create policy pricing_policy_select on app.shop_pricing_policy
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.shop_pricing_policy to authenticated;
drop policy if exists price_ovr_select on app.price_overrides;
create policy price_ovr_select on app.price_overrides
for select to authenticated
using (app._can_see_txn(txn_id));
grant select on app.price_overrides to authenticated;
-- End migration 0008 ----------------------------------------------------