-- ===================================================================== -- 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 ----------------------------------------------------