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,511 @@
-- =====================================================================
-- Migration 0010 — Reporting and alerting (roadmap Step 11).
--
-- Owner-facing read model: Z-reports, daily P&L per service, employee
-- scorecards, and a persistent alerts table fed by detector functions.
--
-- Threat-model rows addressed: 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17,
-- 18, 20, 22, 23, 24, 25.
-- =====================================================================
-- =====================================================================
-- Z-report: one row per closed shift, what the system says vs what the
-- cashier declared vs what was found in the drawer.
-- =====================================================================
create or replace view app.v_z_report as
with cm as (
select sh.id as shift_id,
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
from app.shifts sh
left join app.cash_movements m on m.shift_id = sh.id
group by sh.id
),
txn as (
select sh.id as shift_id,
count(*) filter (where t.status='completed') as txn_count,
count(*) filter (where t.status='voided') as void_count,
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
from app.shifts sh
left join app.transactions t
on t.shift_id = sh.id
group by sh.id
)
select
sh.id as shift_id,
sh.shop_id,
sh.till_id,
sh.user_id as cashier_id,
sh.opened_at,
sh.closed_at,
sh.status,
sh.opening_usd,
sh.opening_lbp,
cm.net_usd as expected_close_usd, -- = sum(cash_movements USD)
cm.net_lbp as expected_close_lbp,
sh.declared_close_usd,
sh.declared_close_lbp,
sh.declared_close_usd - cm.net_usd as variance_usd,
sh.declared_close_lbp - cm.net_lbp as variance_lbp,
txn.txn_count,
txn.void_count,
txn.gross_usd,
txn.gross_lbp,
txn.fee_usd + txn.comm_usd as revenue_usd,
txn.fee_lbp + txn.comm_lbp as revenue_lbp
from app.shifts sh
join cm on cm.shift_id = sh.id
join txn on txn.shift_id = sh.id;
-- =====================================================================
-- Daily P&L per shop / service.
-- =====================================================================
create or replace view app.v_daily_pnl as
select
t.shop_id,
(t.occurred_at at time zone 'UTC')::date as day,
t.service_code,
count(*) filter (where t.status='completed') as txn_count,
sum(t.gross_usd) filter (where t.status='completed') as gross_usd,
sum(t.gross_lbp) filter (where t.status='completed') as gross_lbp,
sum(t.fee_usd) filter (where t.status='completed') as fee_usd,
sum(t.fee_lbp) filter (where t.status='completed') as fee_lbp,
sum(t.commission_usd) filter (where t.status='completed') as comm_usd,
sum(t.commission_lbp) filter (where t.status='completed') as comm_lbp,
count(*) filter (where t.status='voided') as void_count
from app.transactions t
group by t.shop_id, (t.occurred_at at time zone 'UTC')::date, t.service_code;
-- =====================================================================
-- Per-employee scorecard (last 30 days). Owner uses this to spot the
-- cashier whose numbers always look just slightly off.
-- =====================================================================
create or replace view app.v_employee_scorecard_30d as
with base as (
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
(sh.declared_close_usd - z.expected_close_usd) as var_usd,
(sh.declared_close_lbp - z.expected_close_lbp) as var_lbp
from app.shifts sh
join app.v_z_report z on z.shift_id = sh.id
where sh.closed_at >= now() - interval '30 days'
and sh.status = 'closed'
),
voids as (
select t.shop_id, t.user_id as cashier_id,
count(*) as voids_30d,
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes') as late_voids_30d
from app.transactions t
where t.status = 'voided'
and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
),
overrides as (
select t.shop_id, t.user_id as cashier_id,
count(*) as overrides_30d
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
)
select
b.cashier_id,
b.shop_id,
count(*) as shifts_30d,
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
sum(b.var_usd) as total_var_usd,
sum(b.var_lbp) as total_var_lbp,
avg(b.var_usd) as avg_var_usd,
avg(b.var_lbp) as avg_var_lbp,
coalesce(v.voids_30d,0) as voids_30d,
coalesce(v.late_voids_30d,0) as late_voids_30d,
coalesce(o.overrides_30d,0) as overrides_30d
from base b
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
left join overrides o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
-- =====================================================================
-- Detector views (raw signals used by the alert engine).
-- =====================================================================
-- Recon backlog (vector #24)
create or replace view app.v_alert_recon_backlog as
select s.shop_id,
s.id as settlement_id,
s.provider,
s.period_start,
s.period_end,
count(e.id) as open_exceptions
from app.settlements s
join app.reconciliation_exceptions e on e.settlement_id = s.id and e.resolved_at is null
where s.status = 'has_exceptions'
group by s.shop_id, s.id, s.provider, s.period_start, s.period_end;
-- After-hours activity (vector #22)
create or replace view app.v_alert_after_hours as
select t.shop_id,
t.id as txn_id,
t.user_id as cashier_id,
t.occurred_at,
t.gross_usd, t.gross_lbp
from app.transactions t
where t.status = 'completed'
and (extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) < 7
or extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) >= 23);
-- Chronic short cashier (vector #2)
create or replace view app.v_alert_chronic_shorts as
select cashier_id, shop_id,
short_shifts_usd, short_shifts_lbp,
total_var_usd, total_var_lbp
from app.v_employee_scorecard_30d
where short_shifts_usd >= 5 or short_shifts_lbp >= 5
or total_var_usd <= -50 or total_var_lbp <= -1000000;
-- Void spike (vector #10) — >5 voids/day per cashier or any cashier with
-- voids_30d > 20.
create or replace view app.v_alert_void_spikes as
select t.shop_id, t.user_id as cashier_id,
(t.occurred_at at time zone 'Asia/Beirut')::date as day,
count(*) as void_count
from app.transactions t
where t.status = 'voided'
and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (t.occurred_at at time zone 'Asia/Beirut')::date
having count(*) >= 5;
-- Override spike (vector #12)
create or replace view app.v_alert_override_spikes as
select t.shop_id, t.user_id as cashier_id,
(p.created_at at time zone 'Asia/Beirut')::date as day,
count(*) as override_count
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (p.created_at at time zone 'Asia/Beirut')::date
having count(*) >= 3;
-- Stock shrinkage (vector #13)
create or replace view app.v_alert_stock_shrinkage as
select s.shop_id, s.sku,
sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
then -m.qty_delta else 0 end) as shrink_qty_30d,
sum(case when m.type = 'sale_out' then -m.qty_delta else 0 end) as sales_qty_30d
from app.stock_movements m
join app.stock_on_hand s on s.shop_id = m.shop_id and s.sku = m.sku
where m.created_at >= now() - interval '30 days'
group by s.shop_id, s.sku
having sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
then -m.qty_delta else 0 end) >= 5;
-- Voucher loss / damage spike (vector #14)
create or replace view app.v_alert_voucher_writeoffs as
select v.shop_id,
v.sku,
count(*) filter (where v.status in ('damaged','lost')) as bad_30d,
count(*) as total_30d
from app.voucher_inventory v
where coalesce(v.sold_at, v.received_at) >= now() - interval '30 days'
group by v.shop_id, v.sku
having count(*) filter (where v.status in ('damaged','lost'))::numeric
/ nullif(count(*),0)::numeric > 0.02; -- > 2 %
-- =====================================================================
-- Persistent alerts table + detector engine
-- =====================================================================
do $$ begin
create type app.alert_severity as enum ('info','warn','critical');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.alert_kind as enum (
'chronic_short',
'void_spike',
'override_spike',
'voucher_writeoffs',
'stock_shrinkage',
'after_hours',
'recon_backlog',
'aml_structuring',
'aml_burst',
'shift_unclosed',
'chain_break',
'reference_gap'
);
exception when duplicate_object then null; end $$;
create table if not exists app.alerts (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
kind app.alert_kind not null,
severity app.alert_severity not null default 'warn',
subject_id uuid, -- cashier / txn / settlement / shift
payload jsonb not null,
created_at timestamptz not null default now(),
acknowledged_at timestamptz,
acknowledged_by uuid references auth.users(id),
ack_note text,
-- Avoid duplicate alerts for the same condition on the same day:
dedupe_key text not null unique
);
create index if not exists idx_alerts_open on app.alerts(shop_id, kind)
where acknowledged_at is null;
-- Append-only / controlled update.
create or replace function app._alerts_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'alerts cannot be deleted';
end if;
if current_setting('app.alerts_internal', true) is distinct from 'on' then
raise exception 'alerts can only be modified via app.* functions';
end if;
return new;
end;
$$;
drop trigger if exists trg_alerts_guard on app.alerts;
create trigger trg_alerts_guard before update or delete on app.alerts
for each row execute function app._alerts_guard();
create or replace function app._raise_alert(
p_shop uuid, p_kind app.alert_kind, p_severity app.alert_severity,
p_subject uuid, p_payload jsonb, p_dedupe text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare aid uuid;
begin
insert into app.alerts(shop_id, kind, severity, subject_id, payload, dedupe_key)
values (p_shop, p_kind, p_severity, p_subject, p_payload, p_dedupe)
on conflict (dedupe_key) do nothing
returning id into aid;
return aid;
end;
$$;
-- The detector. Idempotent: each rule produces a deterministic
-- `dedupe_key` so re-running it doesn't multiply alerts.
create or replace function app.run_alert_detectors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
-- Chronic shorts (vector #2)
for r in select * from app.v_alert_chronic_shorts loop
if app._raise_alert(r.shop_id, 'chronic_short', 'critical',
r.cashier_id,
jsonb_build_object('short_usd_shifts', r.short_shifts_usd,
'short_lbp_shifts', r.short_shifts_lbp,
'total_var_usd', r.total_var_usd,
'total_var_lbp', r.total_var_lbp),
format('chronic_short:%s:%s:%s', r.shop_id, r.cashier_id, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- Void spikes (vector #10)
for r in select * from app.v_alert_void_spikes loop
if app._raise_alert(r.shop_id, 'void_spike', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.void_count),
format('void_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Override spikes (vector #12)
for r in select * from app.v_alert_override_spikes loop
if app._raise_alert(r.shop_id, 'override_spike', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.override_count),
format('override_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Voucher write-off rate (vector #14)
for r in select * from app.v_alert_voucher_writeoffs loop
if app._raise_alert(r.shop_id, 'voucher_writeoffs', 'critical',
null,
jsonb_build_object('sku', r.sku, 'bad_30d', r.bad_30d, 'total_30d', r.total_30d),
format('voucher_writeoffs:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- Stock shrinkage (vector #13)
for r in select * from app.v_alert_stock_shrinkage loop
if app._raise_alert(r.shop_id, 'stock_shrinkage', 'warn',
null,
jsonb_build_object('sku', r.sku, 'shrink_qty_30d', r.shrink_qty_30d, 'sales_qty_30d', r.sales_qty_30d),
format('stock_shrinkage:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- After-hours (vector #22) — bucket per cashier per day
for r in
select shop_id, cashier_id,
(occurred_at at time zone 'Asia/Beirut')::date as day,
count(*) as cnt,
sum(coalesce(gross_usd,0)) as g_usd,
sum(coalesce(gross_lbp,0)) as g_lbp
from app.v_alert_after_hours
where occurred_at >= now() - interval '7 days'
group by shop_id, cashier_id, (occurred_at at time zone 'Asia/Beirut')::date
loop
if app._raise_alert(r.shop_id, 'after_hours', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.cnt,
'gross_usd', r.g_usd, 'gross_lbp', r.g_lbp),
format('after_hours:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Recon backlog (vector #24)
for r in select * from app.v_alert_recon_backlog loop
if app._raise_alert(r.shop_id, 'recon_backlog', 'critical',
r.settlement_id,
jsonb_build_object('provider', r.provider,
'period_start', r.period_start,
'period_end', r.period_end,
'open_exceptions', r.open_exceptions),
format('recon_backlog:%s', r.settlement_id)
) is not null then n := n + 1; end if;
end loop;
-- AML signals (from 0006)
for r in select * from app.v_aml_structuring_by_customer loop
if app._raise_alert(r.shop_id, 'aml_structuring', 'critical',
r.customer_id,
jsonb_build_object('day', r.day, 'service', r.service_code,
'cnt', r.cnt, 'sum_usd', r.sum_usd, 'sum_lbp', r.sum_lbp),
format('aml_structuring:%s:%s:%s:%s', r.shop_id, r.customer_id, r.service_code, r.day)
) is not null then n := n + 1; end if;
end loop;
for r in select * from app.v_aml_same_beneficiary_burst loop
if app._raise_alert(r.shop_id, 'aml_burst', 'critical',
null,
jsonb_build_object('beneficiary_phone', r.beneficiary_phone,
'window_hour', r.window_hour,
'cashier_count', r.cashier_count, 'cnt', r.cnt),
format('aml_burst:%s:%s:%s', r.shop_id, r.beneficiary_phone, r.window_hour)
) is not null then n := n + 1; end if;
end loop;
-- Shift left open > 18 hours (vector #4)
for r in
select id, shop_id, cashier_id, opened_at
from app.shifts
where status = 'open' and opened_at < now() - interval '18 hours'
loop
if app._raise_alert(r.shop_id, 'shift_unclosed', 'warn',
r.cashier_id,
jsonb_build_object('shift_id', r.id, 'opened_at', r.opened_at),
format('shift_unclosed:%s', r.id)
) is not null then n := n + 1; end if;
end loop;
-- Reference number gaps (vector #20)
for r in select * from app.v_reference_gaps loop
if app._raise_alert(r.shop_id, 'reference_gap', 'critical',
null,
jsonb_build_object('expected', r.expected_ref, 'actual', r.actual_ref),
format('reference_gap:%s:%s', r.shop_id, r.expected_ref)
) is not null then n := n + 1; end if;
end loop;
-- Hash chain break (vector #25) — verify per shop, raise if any row fails
for r in
select s.id as shop_id
from app.shops s
where exists (select 1 from app.verify_chain(s.id) v where v.ok = false)
loop
if app._raise_alert(r.shop_id, 'chain_break', 'critical',
null,
jsonb_build_object('detected_at', now()),
format('chain_break:%s:%s', r.shop_id, to_char(now(),'YYYYMMDDHH24'))
) is not null then n := n + 1; end if;
end loop;
return n;
end;
$$;
revoke all on function app.run_alert_detectors() from public;
grant execute on function app.run_alert_detectors() to authenticated;
-- Acknowledge an alert (owner only, audited).
create or replace function app.ack_alert(p_alert uuid, p_note text)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare a app.alerts%rowtype;
begin
if p_note is null or length(btrim(p_note)) < 5 then
raise exception 'ack note >= 5 chars required';
end if;
select * into a from app.alerts where id = p_alert;
if a.id is null then raise exception 'alert not found'; end if;
if not app.has_role_in_shop(a.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if a.acknowledged_at is not null then
raise exception 'alert already acknowledged';
end if;
perform set_config('app.alerts_internal', 'on', true);
update app.alerts
set acknowledged_at = now(), acknowledged_by = auth.uid(), ack_note = p_note
where id = p_alert;
perform set_config('app.alerts_internal', 'off', true);
perform app.log_auth_event('alert_ack', a.shop_id, null,
jsonb_build_object('alert_id', p_alert, 'kind', a.kind));
end;
$$;
revoke all on function app.ack_alert(uuid, text) from public;
grant execute on function app.ack_alert(uuid, text) to authenticated;
-- =====================================================================
-- Owner dashboard rollup
-- =====================================================================
create or replace view app.v_owner_dashboard as
select
s.id as shop_id,
s.name as shop_name,
(select count(*) from app.shifts where shop_id=s.id and status='open') as open_shifts,
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null) as open_alerts,
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null
and severity='critical') as critical_alerts,
(select count(*) from app.reconciliation_exceptions e
join app.settlements st on st.id=e.settlement_id
where st.shop_id=s.id and e.resolved_at is null) as open_recon_exceptions,
(select coalesce(sum(gross_usd),0) from app.v_daily_pnl
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_usd,
(select coalesce(sum(gross_lbp),0) from app.v_daily_pnl
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_lbp
from app.shops s;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.alerts enable row level security;
alter table app.alerts force row level security;
revoke insert, update, delete on app.alerts from authenticated;
drop policy if exists alerts_select on app.alerts;
create policy alerts_select on app.alerts
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.alerts to authenticated;
-- End migration 0010 ----------------------------------------------------