431 lines
18 KiB
PL/PgSQL
431 lines
18 KiB
PL/PgSQL
-- =====================================================================
|
||
-- 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 ----------------------------------------------------
|