Add cash management schema and immediate variance alerts

This commit is contained in:
Krikorios
2026-05-06 10:51:55 +03:00
parent 1a3de58de6
commit 1896cbdd11
106 changed files with 16800 additions and 4604 deletions
@@ -0,0 +1,430 @@
-- =====================================================================
-- 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 ----------------------------------------------------