-- ===================================================================== -- 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; -- Cashier–manager 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 ----------------------------------------------------