Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -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;
|
||||
|
||||
-- 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 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user