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
+348
View File
@@ -0,0 +1,348 @@
-- =====================================================================
-- Migration 0001 — Auth, organizational hierarchy, RLS foundations.
--
-- Implements roadmap Step 1 (identity & access) and Step 2 (org & master
-- data). No money tables yet; those come in 0002+. Every table is created
-- with RLS enabled and a deny-by-default posture; specific policies are
-- added inline.
--
-- Threat-model rows addressed: 9, 18, 20, 24, 25.
-- =====================================================================
-- Required extensions ---------------------------------------------------
create extension if not exists "pgcrypto"; -- gen_random_uuid, digest
create extension if not exists "citext"; -- case-insensitive text
-- Dedicated schema for app data (keeps `public` clean) ------------------
create schema if not exists app;
-- Revoke broad defaults; we will grant explicitly per role.
revoke all on schema app from public;
grant usage on schema app to authenticated;
-- =====================================================================
-- Roles
-- =====================================================================
-- We model business roles as an enum, separate from Postgres/Supabase
-- roles. Supabase still uses `authenticated`/`anon`; the business role is
-- read from `app.user_shop_assignments` per shop.
do $$ begin
create type app.business_role as enum ('owner', 'manager', 'cashier', 'auditor');
exception when duplicate_object then null; end $$;
-- =====================================================================
-- Shops, tills, users
-- =====================================================================
create table if not exists app.shops (
id uuid primary key default gen_random_uuid(),
name text not null,
address text,
omt_agent_code text unique,
alfa_dealer_code text unique,
touch_dealer_code text unique,
created_at timestamptz not null default now(),
created_by uuid references auth.users(id)
);
create table if not exists app.tills (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
name text not null,
-- Pin a till to a hardware device. New devices must be registered by an
-- owner; blocks vector #20 (second undeclared till on same machine).
device_fingerprint text unique,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (shop_id, name)
);
-- Profile mirror of auth.users so we can attach business attributes
-- without granting clients access to the auth schema.
create table if not exists app.user_profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
full_name text not null,
phone text,
-- 6-digit PIN, salted+hashed. Never store plaintext.
pin_hash text,
pin_set_at timestamptz,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table if not exists app.user_shop_assignments (
user_id uuid not null references auth.users(id) on delete cascade,
shop_id uuid not null references app.shops(id) on delete cascade,
role app.business_role not null,
assigned_at timestamptz not null default now(),
assigned_by uuid references auth.users(id),
primary key (user_id, shop_id)
);
create index if not exists idx_assignments_shop on app.user_shop_assignments(shop_id);
create index if not exists idx_assignments_role on app.user_shop_assignments(shop_id, role);
-- =====================================================================
-- Helper functions (SECURITY DEFINER) used by RLS policies.
-- These run with the function owner's privileges, so they can read
-- assignment rows even when the calling user cannot read the table.
-- =====================================================================
create or replace function app.current_user_id()
returns uuid
language sql
stable
as $$ select auth.uid() $$;
create or replace function app.has_role_in_shop(p_shop uuid, p_role app.business_role)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1
from app.user_shop_assignments a
where a.user_id = auth.uid()
and a.shop_id = p_shop
and a.role = p_role
);
$$;
create or replace function app.has_any_role_in_shop(p_shop uuid, p_roles app.business_role[])
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1
from app.user_shop_assignments a
where a.user_id = auth.uid()
and a.shop_id = p_shop
and a.role = any(p_roles)
);
$$;
create or replace function app.is_owner_anywhere()
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.user_shop_assignments a
where a.user_id = auth.uid() and a.role = 'owner'
);
$$;
revoke all on function app.has_role_in_shop(uuid, app.business_role) from public;
revoke all on function app.has_any_role_in_shop(uuid, app.business_role[]) from public;
revoke all on function app.is_owner_anywhere() from public;
grant execute on function app.has_role_in_shop(uuid, app.business_role) to authenticated;
grant execute on function app.has_any_role_in_shop(uuid, app.business_role[]) to authenticated;
grant execute on function app.is_owner_anywhere() to authenticated;
-- =====================================================================
-- PIN management. Plaintext PINs never leave the server.
-- =====================================================================
create or replace function app.set_my_pin(p_pin text)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if p_pin !~ '^[0-9]{6}$' then
raise exception 'PIN must be exactly 6 digits';
end if;
insert into app.user_profiles(user_id, full_name, pin_hash, pin_set_at)
values (auth.uid(), coalesce((select full_name from app.user_profiles where user_id = auth.uid()), 'Unnamed'),
crypt(p_pin, gen_salt('bf', 10)), now())
on conflict (user_id) do update
set pin_hash = crypt(p_pin, gen_salt('bf', 10)),
pin_set_at = now();
end;
$$;
create or replace function app.verify_my_pin(p_pin text)
returns boolean
language plpgsql
security definer
set search_path = app, public
as $$
declare h text;
begin
select pin_hash into h from app.user_profiles where user_id = auth.uid();
if h is null then return false; end if;
return h = crypt(p_pin, h);
end;
$$;
revoke all on function app.set_my_pin(text) from public;
revoke all on function app.verify_my_pin(text) from public;
grant execute on function app.set_my_pin(text) to authenticated;
grant execute on function app.verify_my_pin(text) to authenticated;
-- =====================================================================
-- Audit log of authentication / authorization events.
-- Append-only: revoke UPDATE and DELETE; only INSERT via function.
-- =====================================================================
create table if not exists app.auth_events (
id bigserial primary key,
occurred_at timestamptz not null default now(),
user_id uuid,
event_type text not null, -- login, pin_ok, pin_fail, role_change, device_register, ...
shop_id uuid,
device_fingerprint text,
metadata jsonb not null default '{}'::jsonb
);
create index if not exists idx_auth_events_user on app.auth_events(user_id, occurred_at desc);
create index if not exists idx_auth_events_shop on app.auth_events(shop_id, occurred_at desc);
create or replace function app.log_auth_event(
p_event_type text,
p_shop uuid,
p_device text,
p_metadata jsonb
)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
insert into app.auth_events(user_id, event_type, shop_id, device_fingerprint, metadata)
values (auth.uid(), p_event_type, p_shop, p_device, coalesce(p_metadata, '{}'::jsonb));
end;
$$;
revoke all on function app.log_auth_event(text, uuid, text, jsonb) from public;
grant execute on function app.log_auth_event(text, uuid, text, jsonb) to authenticated;
-- =====================================================================
-- RLS — deny by default, then allow per role.
-- =====================================================================
alter table app.shops enable row level security;
alter table app.tills enable row level security;
alter table app.user_profiles enable row level security;
alter table app.user_shop_assignments enable row level security;
alter table app.auth_events enable row level security;
-- Force RLS even for table owners (defense in depth against insider edits,
-- threat-model row #24).
alter table app.shops force row level security;
alter table app.tills force row level security;
alter table app.user_profiles force row level security;
alter table app.user_shop_assignments force row level security;
alter table app.auth_events force row level security;
-- shops: owners and assigned users can see their shops.
drop policy if exists shops_select on app.shops;
create policy shops_select on app.shops
for select to authenticated
using (
app.is_owner_anywhere()
or exists (
select 1 from app.user_shop_assignments a
where a.shop_id = shops.id and a.user_id = auth.uid()
)
);
-- Only owners can create/modify shops, and never via direct UPDATE of
-- security-relevant columns; we still permit it here but real changes
-- should go through dedicated functions later.
drop policy if exists shops_write_owner on app.shops;
create policy shops_write_owner on app.shops
for all to authenticated
using (app.is_owner_anywhere())
with check (app.is_owner_anywhere());
-- tills: visible to everyone assigned to the shop, writable only by owners.
drop policy if exists tills_select on app.tills;
create policy tills_select on app.tills
for select to authenticated
using (
app.is_owner_anywhere()
or exists (
select 1 from app.user_shop_assignments a
where a.shop_id = tills.shop_id and a.user_id = auth.uid()
)
);
drop policy if exists tills_write_owner on app.tills;
create policy tills_write_owner on app.tills
for all to authenticated
using (app.has_role_in_shop(tills.shop_id, 'owner'))
with check (app.has_role_in_shop(tills.shop_id, 'owner'));
-- user_profiles: a user can read/update their own profile (but PIN is
-- changed only via the set_my_pin function). Owners can read all.
drop policy if exists profiles_select_self_or_owner on app.user_profiles;
create policy profiles_select_self_or_owner on app.user_profiles
for select to authenticated
using (user_id = auth.uid() or app.is_owner_anywhere());
drop policy if exists profiles_update_self on app.user_profiles;
create policy profiles_update_self on app.user_profiles
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
-- user_shop_assignments: a user can see their own assignments; owners can
-- see/manage all assignments in their own shops.
drop policy if exists assignments_select on app.user_shop_assignments;
create policy assignments_select on app.user_shop_assignments
for select to authenticated
using (
user_id = auth.uid()
or app.has_role_in_shop(shop_id, 'owner')
);
drop policy if exists assignments_write_owner on app.user_shop_assignments;
create policy assignments_write_owner on app.user_shop_assignments
for all to authenticated
using (app.has_role_in_shop(shop_id, 'owner'))
with check (app.has_role_in_shop(shop_id, 'owner'));
-- auth_events: nobody writes directly; only the log_auth_event function.
-- Reads: a user sees their own events; owners see all in their shops.
revoke insert, update, delete on app.auth_events from authenticated;
drop policy if exists auth_events_select on app.auth_events;
create policy auth_events_select on app.auth_events
for select to authenticated
using (
user_id = auth.uid()
or (shop_id is not null and app.has_role_in_shop(shop_id, 'owner'))
);
-- =====================================================================
-- Hard prohibitions — no DELETE on auth_events from anyone (including
-- service role used by the app). Only DBA at psql can DELETE, and that
-- itself should be audited at the infrastructure level.
-- =====================================================================
revoke delete on app.auth_events from authenticated;
-- Note: in Supabase, the `service_role` bypasses RLS but still respects
-- table grants. Revoke explicitly:
do $$ begin
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'revoke delete on app.auth_events from service_role';
execute 'revoke update on app.auth_events from service_role';
end if;
end $$;
-- =====================================================================
-- Grants for ordinary table access (RLS still applies).
-- =====================================================================
grant select on app.shops to authenticated;
grant insert, update on app.shops to authenticated;
grant select on app.tills to authenticated;
grant insert, update on app.tills to authenticated;
grant select, update on app.user_profiles to authenticated;
grant select, insert, update, delete on app.user_shop_assignments to authenticated;
grant select on app.auth_events to authenticated;
-- End migration 0001 ----------------------------------------------------
@@ -0,0 +1,391 @@
-- =====================================================================
-- Migration 0002 — Shifts and cash drawer (roadmap Step 3).
--
-- Implements the cash-control backbone:
-- * One open shift per till at any time (vector #20).
-- * Blind close: cashier declares cash, expected is computed by the
-- system; both are stored, with variance (vectors #7, #14).
-- * Cash movements typed and append-only (vector #2, #22).
-- * No backdating: occurred_at = now() server-side (vector #25).
-- * Forced shift close before next shift opens (vector #8).
--
-- Threat-model rows addressed: 2, 7, 8, 14, 20, 22, 25.
-- =====================================================================
-- =====================================================================
-- Enums
-- =====================================================================
do $$ begin
create type app.shift_status as enum ('open', 'declared', 'closed');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.cash_movement_type as enum (
'sale_in', -- cash received from a customer
'payout_out', -- cash paid to a customer (e.g. OMT receive)
'drop_to_safe', -- cashier removes cash from till to safe
'bank_deposit', -- cash leaves the shop to the bank
'expense', -- petty cash spent
'fx_swap_in', -- one leg of a currency exchange
'fx_swap_out', -- the other leg
'opening_float', -- recorded at shift open
'adjustment' -- manager-approved correction (always audited)
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.currency_code as enum ('USD', 'LBP');
exception when duplicate_object then null; end $$;
-- =====================================================================
-- Shifts
-- =====================================================================
create table if not exists app.shifts (
id uuid primary key default gen_random_uuid(),
till_id uuid not null references app.tills(id) on delete restrict,
shop_id uuid not null references app.shops(id) on delete restrict,
user_id uuid not null references auth.users(id) on delete restrict,
status app.shift_status not null default 'open',
opened_at timestamptz not null default now(),
opened_by uuid not null references auth.users(id),
opening_usd numeric(14,2) not null check (opening_usd >= 0),
opening_lbp numeric(18,0) not null check (opening_lbp >= 0),
-- Phase 1 of close: cashier declares the count.
declared_at timestamptz,
declared_close_usd numeric(14,2) check (declared_close_usd >= 0),
declared_close_lbp numeric(18,0) check (declared_close_lbp >= 0),
-- Phase 2 of close: system computes expected; cashier cannot edit.
closed_at timestamptz,
closed_by uuid references auth.users(id),
expected_close_usd numeric(14,2),
expected_close_lbp numeric(18,0),
variance_usd numeric(14,2),
variance_lbp numeric(18,0),
-- One open or declared shift per till at any time.
constraint shifts_status_dates_ok check (
(status = 'open' and declared_at is null and closed_at is null)
or (status = 'declared' and declared_at is not null and closed_at is null)
or (status = 'closed' and declared_at is not null and closed_at is not null)
)
);
create index if not exists idx_shifts_till_status on app.shifts(till_id, status);
create index if not exists idx_shifts_shop_open on app.shifts(shop_id, opened_at desc);
create index if not exists idx_shifts_user on app.shifts(user_id, opened_at desc);
-- Partial unique index: at most one non-closed shift per till.
create unique index if not exists uq_one_active_shift_per_till
on app.shifts(till_id) where status <> 'closed';
-- =====================================================================
-- Cash movements (append-only)
-- =====================================================================
create table if not exists app.cash_movements (
id uuid primary key default gen_random_uuid(),
shift_id uuid not null references app.shifts(id) on delete restrict,
occurred_at timestamptz not null default now(),
type app.cash_movement_type not null,
currency app.currency_code not null,
-- Signed amount: positive = cash into the till, negative = cash out.
amount numeric(18,2) not null check (amount <> 0),
ref_txn_id uuid, -- filled later when ledger table exists (FK added in 0003)
note text,
created_by uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now()
);
create index if not exists idx_cash_mov_shift on app.cash_movements(shift_id, occurred_at);
create index if not exists idx_cash_mov_txn on app.cash_movements(ref_txn_id);
-- =====================================================================
-- Triggers — block edits and back-dating
-- =====================================================================
-- Cash movements: insert-only.
create or replace function app.cash_movements_no_update_delete()
returns trigger language plpgsql as $$
begin
raise exception 'cash_movements is append-only';
end;
$$;
drop trigger if exists trg_cash_mov_no_update on app.cash_movements;
create trigger trg_cash_mov_no_update
before update or delete on app.cash_movements
for each row execute function app.cash_movements_no_update_delete();
-- Force occurred_at = now() and created_by = auth.uid() on insert.
create or replace function app.cash_movements_stamp()
returns trigger language plpgsql as $$
begin
new.occurred_at := now(); -- vector #25: no backdating
new.created_at := now();
new.created_by := auth.uid();
-- The shift must be open and belong to the same user / till must be active.
if not exists (
select 1 from app.shifts s
where s.id = new.shift_id and s.status = 'open'
) then
raise exception 'cash movement requires an OPEN shift (got shift %)', new.shift_id;
end if;
return new;
end;
$$;
drop trigger if exists trg_cash_mov_stamp on app.cash_movements;
create trigger trg_cash_mov_stamp
before insert on app.cash_movements
for each row execute function app.cash_movements_stamp();
-- Shifts: tightly constrain UPDATE paths. Only specific transitions are
-- allowed and they must come through the SECURITY DEFINER functions
-- below (which set a session GUC the trigger checks for).
create or replace function app.shifts_guard_update()
returns trigger language plpgsql as $$
begin
if current_setting('app.shift_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on app.shifts is not allowed; use app.declare_close / app.finalize_close';
end if;
return new;
end;
$$;
drop trigger if exists trg_shifts_guard_update on app.shifts;
create trigger trg_shifts_guard_update
before update on app.shifts
for each row execute function app.shifts_guard_update();
create or replace function app.shifts_no_delete()
returns trigger language plpgsql as $$
begin
raise exception 'shifts cannot be deleted';
end;
$$;
drop trigger if exists trg_shifts_no_delete on app.shifts;
create trigger trg_shifts_no_delete
before delete on app.shifts
for each row execute function app.shifts_no_delete();
-- =====================================================================
-- SECURITY DEFINER functions — the only legal way to mutate shifts.
-- =====================================================================
-- Open a new shift on a till for the current user.
create or replace function app.open_shift(
p_till_id uuid,
p_opening_usd numeric,
p_opening_lbp numeric
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_shift uuid;
begin
if p_opening_usd is null or p_opening_lbp is null then
raise exception 'opening counts are required';
end if;
if p_opening_usd < 0 or p_opening_lbp < 0 then
raise exception 'opening counts must be non-negative';
end if;
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
if v_shop is null then
raise exception 'till % not found or inactive', p_till_id;
end if;
-- Caller must be a cashier or manager in this shop.
if not app.has_any_role_in_shop(v_shop, array['cashier','manager']::app.business_role[]) then
raise exception 'not authorized to open a shift on this till';
end if;
-- Reject if any non-closed shift exists on this till.
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
raise exception 'till % already has an active shift; close it first', p_till_id;
end if;
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
values (p_till_id, v_shop, auth.uid(), auth.uid(), p_opening_usd, p_opening_lbp)
returning id into v_shift;
-- Record the opening float as a cash movement for clean ledgers.
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
perform app.log_auth_event('shift_opened', v_shop, null,
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id));
return v_shift;
end;
$$;
-- Phase 1 of close: cashier declares the cash count. Expected is NOT
-- revealed until phase 2 (vector #14: blind close).
create or replace function app.declare_close(
p_shift_id uuid,
p_declared_close_usd numeric,
p_declared_close_lbp numeric
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s record;
begin
select * into s from app.shifts where id = p_shift_id;
if s.id is null then raise exception 'shift not found'; end if;
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
raise exception 'only the shift owner or a manager may declare close';
end if;
if s.status <> 'open' then
raise exception 'shift % is not open (status=%)', p_shift_id, s.status;
end if;
if p_declared_close_usd is null or p_declared_close_lbp is null
or p_declared_close_usd < 0 or p_declared_close_lbp < 0 then
raise exception 'declared counts must be non-negative numbers';
end if;
perform set_config('app.shift_internal', 'on', true);
update app.shifts
set status = 'declared',
declared_at = now(),
declared_close_usd = p_declared_close_usd,
declared_close_lbp = p_declared_close_lbp
where id = p_shift_id;
perform set_config('app.shift_internal', 'off', true);
perform app.log_auth_event('shift_declared', s.shop_id, null,
jsonb_build_object('shift_id', p_shift_id));
end;
$$;
-- Phase 2 of close: compute expected and variance, lock the shift.
create or replace function app.finalize_close(p_shift_id uuid)
returns table (
expected_usd numeric,
expected_lbp numeric,
variance_usd numeric,
variance_lbp numeric
)
language plpgsql
security definer
set search_path = app, public
as $$
declare
s record;
v_exp_usd numeric;
v_exp_lbp numeric;
begin
select * into s from app.shifts where id = p_shift_id;
if s.id is null then raise exception 'shift not found'; end if;
if s.status <> 'declared' then
raise exception 'shift % must be in DECLARED state to finalize (was %)', p_shift_id, s.status;
end if;
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
raise exception 'only the shift owner or a manager may finalize close';
end if;
-- Expected = sum of signed cash movements in each currency.
-- opening_float rows are already part of cash_movements, so the sum is
-- the full expected drawer count.
select
coalesce(sum(case when currency = 'USD' then amount end), 0),
coalesce(sum(case when currency = 'LBP' then amount end), 0)
into v_exp_usd, v_exp_lbp
from app.cash_movements
where shift_id = p_shift_id;
perform set_config('app.shift_internal', 'on', true);
update app.shifts
set status = 'closed',
closed_at = now(),
closed_by = auth.uid(),
expected_close_usd = v_exp_usd,
expected_close_lbp = v_exp_lbp,
variance_usd = s.declared_close_usd - v_exp_usd,
variance_lbp = s.declared_close_lbp - v_exp_lbp
where id = p_shift_id;
perform set_config('app.shift_internal', 'off', true);
perform app.log_auth_event('shift_closed', s.shop_id, null,
jsonb_build_object(
'shift_id', p_shift_id,
'variance_usd', s.declared_close_usd - v_exp_usd,
'variance_lbp', s.declared_close_lbp - v_exp_lbp
));
return query
select v_exp_usd, v_exp_lbp,
s.declared_close_usd - v_exp_usd,
s.declared_close_lbp - v_exp_lbp;
end;
$$;
revoke all on function app.open_shift(uuid, numeric, numeric) from public;
revoke all on function app.declare_close(uuid, numeric, numeric) from public;
revoke all on function app.finalize_close(uuid) from public;
grant execute on function app.open_shift(uuid, numeric, numeric) to authenticated;
grant execute on function app.declare_close(uuid, numeric, numeric) to authenticated;
grant execute on function app.finalize_close(uuid) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.shifts enable row level security;
alter table app.cash_movements enable row level security;
alter table app.shifts force row level security;
alter table app.cash_movements force row level security;
-- Block direct INSERT/UPDATE on shifts; only the SECURITY DEFINER
-- functions above (which run as the function owner) may write.
revoke insert, update, delete on app.shifts from authenticated;
drop policy if exists shifts_select on app.shifts;
create policy shifts_select on app.shifts
for select to authenticated
using (
user_id = auth.uid()
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
);
-- Cash movements: direct INSERT permitted (with RLS check) so cashiers
-- can record sale_in / payout_out from the txn flow; UPDATE/DELETE are
-- already blocked by triggers.
drop policy if exists cash_mov_select on app.cash_movements;
create policy cash_mov_select on app.cash_movements
for select to authenticated
using (
exists (
select 1 from app.shifts s
where s.id = cash_movements.shift_id
and (
s.user_id = auth.uid()
or app.has_any_role_in_shop(s.shop_id, array['owner','manager','auditor']::app.business_role[])
)
)
);
drop policy if exists cash_mov_insert_in_open_shift on app.cash_movements;
create policy cash_mov_insert_in_open_shift on app.cash_movements
for insert to authenticated
with check (
exists (
select 1 from app.shifts s
where s.id = cash_movements.shift_id
and s.status = 'open'
and s.user_id = auth.uid()
)
);
grant select on app.shifts to authenticated;
grant select, insert on app.cash_movements to authenticated;
-- End migration 0002 ----------------------------------------------------
@@ -0,0 +1,525 @@
-- =====================================================================
-- Migration 0003 — Universal transaction ledger (roadmap Step 4).
--
-- One append-only table for every customer-facing transaction
-- (OMT send/receive, bill payment, recharge, goods sale, etc.).
-- Service-specific detail tables are added in 0004.
--
-- Design choices and the threats they kill:
--
-- * INSERT-only at the SQL level. UPDATE is allowed only by the
-- dedicated `void_transaction` function, and it can only flip the
-- status to 'voided' plus set void fields. Triggers enforce this even
-- against superuser app roles. (vectors #1, #2, #11, #18)
--
-- * Sequential `reference_no` per shop, allocated by a Postgres
-- sequence inside a SECURITY DEFINER function — gaps are visible and
-- a daily report can flag missing numbers. (vector #1)
--
-- * Row hash chain: each row stores a sha256 of its own canonical
-- content + the previous row's hash for the same shop. Anchored
-- daily off-site, this detects silent edits even by an insider DBA.
-- (vector #24)
--
-- * `external_ref` (OMT code, recharge confirmation, etc.) is unique
-- per provider — blocks replay of an old receipt to a new customer.
-- (vector #19)
--
-- * Server-stamped `occurred_at`, `created_by`, and shift/shop/till
-- ids — cashier can not backdate or attribute to someone else.
-- (vectors #20, #25)
--
-- * Voids are bound to a 10-minute window (configurable) for cashier
-- self-service, and require a manager `void_approved_by` after that.
-- (vector #11)
--
-- * Cash movements (0002) get an FK to this ledger so every cash
-- in/out is traceable to a transaction or to an explicit non-sale
-- movement (drop, expense, swap...).
-- =====================================================================
-- =====================================================================
-- Enums and reference data
-- =====================================================================
do $$ begin
create type app.txn_status as enum ('completed', 'voided');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.payment_method as enum (
'cash_usd', 'cash_lbp', 'whish', 'omt_wallet', 'card', 'bank_transfer'
);
exception when duplicate_object then null; end $$;
-- Service catalog (seeded at the bottom of this file).
create table if not exists app.services (
code text primary key,
name text not null,
category text not null,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Per-shop receipt-number sequences ------------------------------------
create table if not exists app.shop_sequences (
shop_id uuid primary key references app.shops(id) on delete cascade,
next_value bigint not null default 1
);
-- =====================================================================
-- The ledger
-- =====================================================================
create table if not exists app.transactions (
id uuid primary key default gen_random_uuid(),
-- Routing
shift_id uuid not null references app.shifts(id) on delete restrict,
shop_id uuid not null references app.shops(id) on delete restrict,
till_id uuid not null references app.tills(id) on delete restrict,
user_id uuid not null references auth.users(id) on delete restrict,
service_code text not null references app.services(code),
-- Identifiers
occurred_at timestamptz not null default now(),
reference_no bigint not null, -- per shop, sequential
external_ref text, -- OMT code, recharge id...
external_ref_provider text, -- 'OMT','ALFA','TOUCH','OGERO',...
status app.txn_status not null default 'completed',
-- Money (dual-currency on the same row; either side may be 0)
gross_usd numeric(14,2) not null default 0 check (gross_usd >= 0),
gross_lbp numeric(18,0) not null default 0 check (gross_lbp >= 0),
fee_usd numeric(14,2) not null default 0 check (fee_usd >= 0),
fee_lbp numeric(18,0) not null default 0 check (fee_lbp >= 0),
commission_usd numeric(14,2) not null default 0 check (commission_usd >= 0),
commission_lbp numeric(18,0) not null default 0 check (commission_lbp >= 0),
fx_rate_used numeric(18,4), -- USD/LBP at moment of txn
payment_method app.payment_method not null,
-- Counterparty (used by various services; child tables hold the rest)
customer_id uuid, -- FK added in 0005 (KYC module)
beneficiary_name text,
beneficiary_phone text,
msisdn text,
operator text,
product_code text,
voucher_serial text,
notes text,
receipt_url text,
-- Audit
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id),
voided_at timestamptz,
voided_by uuid references auth.users(id),
void_reason text,
void_approved_by uuid references auth.users(id),
-- Hash chain (per shop)
row_hash bytea not null,
prev_row_hash bytea,
-- Constraints
constraint txn_unique_per_shop_ref unique (shop_id, reference_no),
constraint txn_unique_external_ref unique (external_ref_provider, external_ref),
constraint txn_void_consistency check (
(status = 'completed' and voided_at is null and voided_by is null and void_reason is null)
or (status = 'voided' and voided_at is not null and voided_by is not null and void_reason is not null)
)
);
create index if not exists idx_txn_shop_time on app.transactions(shop_id, occurred_at desc);
create index if not exists idx_txn_shift on app.transactions(shift_id, occurred_at);
create index if not exists idx_txn_user_time on app.transactions(user_id, occurred_at desc);
create index if not exists idx_txn_service on app.transactions(service_code, occurred_at desc);
create index if not exists idx_txn_status on app.transactions(status) where status = 'voided';
create index if not exists idx_txn_msisdn on app.transactions(msisdn) where msisdn is not null;
create index if not exists idx_txn_external on app.transactions(external_ref_provider, external_ref);
-- Now that transactions exists, attach the deferred FK from cash_movements.
alter table app.cash_movements
drop constraint if exists cash_movements_ref_txn_fk;
alter table app.cash_movements
add constraint cash_movements_ref_txn_fk
foreign key (ref_txn_id) references app.transactions(id) on delete restrict;
-- =====================================================================
-- Hash-chain helpers
-- =====================================================================
create or replace function app.txn_canonical_payload(t app.transactions)
returns text
language sql
immutable
as $$
select jsonb_build_object(
'id', t.id,
'shop_id', t.shop_id,
'till_id', t.till_id,
'shift_id', t.shift_id,
'user_id', t.user_id,
'service_code', t.service_code,
'occurred_at', to_char(t.occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MSOF'),
'reference_no', t.reference_no,
'external_ref', t.external_ref,
'external_ref_provider', t.external_ref_provider,
'status', t.status,
'gross_usd', t.gross_usd,
'gross_lbp', t.gross_lbp,
'fee_usd', t.fee_usd,
'fee_lbp', t.fee_lbp,
'commission_usd', t.commission_usd,
'commission_lbp', t.commission_lbp,
'fx_rate_used', t.fx_rate_used,
'payment_method', t.payment_method,
'customer_id', t.customer_id,
'beneficiary_name', t.beneficiary_name,
'beneficiary_phone', t.beneficiary_phone,
'msisdn', t.msisdn,
'operator', t.operator,
'product_code', t.product_code,
'voucher_serial', t.voucher_serial,
'notes', t.notes,
'receipt_url', t.receipt_url,
'created_by', t.created_by,
'voided_at', t.voided_at,
'voided_by', t.voided_by,
'void_reason', t.void_reason,
'void_approved_by', t.void_approved_by
)::text;
$$;
create or replace function app.txn_compute_hash(t app.transactions, prev bytea)
returns bytea
language sql
immutable
as $$
select digest(coalesce(prev, '\x'::bytea) || convert_to(app.txn_canonical_payload(t), 'UTF8'), 'sha256');
$$;
-- =====================================================================
-- Triggers — block raw writes; allow only what we sanction
-- =====================================================================
-- Block direct UPDATE/DELETE except when our SECURITY DEFINER void
-- function turns on the session GUC.
create or replace function app.txn_guard_update_delete()
returns trigger language plpgsql as $$
begin
if (tg_op = 'DELETE') then
raise exception 'transactions cannot be deleted';
end if;
if current_setting('app.txn_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on app.transactions is not allowed; use app.void_transaction';
end if;
-- Even via the void path, only the void/status fields may change.
if (new.id <> old.id
or new.shop_id <> old.shop_id
or new.till_id <> old.till_id
or new.shift_id <> old.shift_id
or new.user_id <> old.user_id
or new.service_code <> old.service_code
or new.occurred_at <> old.occurred_at
or new.reference_no <> old.reference_no
or coalesce(new.external_ref,'') <> coalesce(old.external_ref,'')
or coalesce(new.external_ref_provider,'') <> coalesce(old.external_ref_provider,'')
or new.gross_usd <> old.gross_usd
or new.gross_lbp <> old.gross_lbp
or new.fee_usd <> old.fee_usd
or new.fee_lbp <> old.fee_lbp
or new.commission_usd <> old.commission_usd
or new.commission_lbp <> old.commission_lbp
or coalesce(new.fx_rate_used, -1) <> coalesce(old.fx_rate_used, -1)
or new.payment_method <> old.payment_method
or new.created_by <> old.created_by
or new.created_at <> old.created_at) then
raise exception 'only status/void fields may change on a transaction';
end if;
return new;
end;
$$;
drop trigger if exists trg_txn_guard_update on app.transactions;
create trigger trg_txn_guard_update
before update on app.transactions
for each row execute function app.txn_guard_update_delete();
drop trigger if exists trg_txn_guard_delete on app.transactions;
create trigger trg_txn_guard_delete
before delete on app.transactions
for each row execute function app.txn_guard_update_delete();
-- Server stamping + hash chain on insert.
create or replace function app.txn_before_insert()
returns trigger
language plpgsql
as $$
declare
v_prev_hash bytea;
v_seq bigint;
v_shift app.shifts%rowtype;
begin
-- Caller identity / time are server-controlled.
new.created_by := auth.uid();
new.created_at := now();
new.occurred_at := now();
new.status := 'completed';
new.voided_at := null;
new.voided_by := null;
new.void_reason := null;
new.void_approved_by := null;
-- Shift must be open and owned by the caller; shop/till derived from it.
select * into v_shift from app.shifts where id = new.shift_id;
if v_shift.id is null then
raise exception 'shift % not found', new.shift_id;
end if;
if v_shift.status <> 'open' then
raise exception 'cannot post a transaction to a % shift', v_shift.status;
end if;
if v_shift.user_id <> auth.uid() then
raise exception 'only the shift owner may post transactions to it';
end if;
new.shop_id := v_shift.shop_id;
new.till_id := v_shift.till_id;
new.user_id := v_shift.user_id;
-- Allocate the shop's next reference number (advisory lock keeps it
-- gap-free under concurrency).
perform pg_advisory_xact_lock(hashtext('shop_seq:' || new.shop_id::text));
insert into app.shop_sequences(shop_id, next_value)
values (new.shop_id, 1)
on conflict (shop_id) do nothing;
update app.shop_sequences
set next_value = next_value + 1
where shop_id = new.shop_id
returning next_value - 1 into v_seq;
new.reference_no := v_seq;
-- Compute hash linking to previous row in this shop.
select row_hash into v_prev_hash
from app.transactions
where shop_id = new.shop_id
order by reference_no desc
limit 1;
new.prev_row_hash := v_prev_hash;
new.row_hash := app.txn_compute_hash(new, v_prev_hash);
return new;
end;
$$;
drop trigger if exists trg_txn_before_insert on app.transactions;
create trigger trg_txn_before_insert
before insert on app.transactions
for each row execute function app.txn_before_insert();
-- =====================================================================
-- Void
-- =====================================================================
-- Configurable self-service void window (minutes).
create table if not exists app.system_settings (
key text primary key,
value text not null
);
insert into app.system_settings(key, value)
values ('void_self_window_minutes', '10')
on conflict (key) do nothing;
create or replace function app.void_transaction(
p_txn_id uuid,
p_reason text,
p_approver_pin text default null -- required for manager approval path
)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
s app.shifts%rowtype;
window_min int;
needs_manager boolean;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'transaction not found'; end if;
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'a reason of at least 5 characters is required';
end if;
select * into s from app.shifts where id = t.shift_id;
if s.status <> 'open' then
raise exception 'cannot void a transaction whose shift is no longer open';
end if;
select coalesce(value::int, 10) into window_min
from app.system_settings where key = 'void_self_window_minutes';
needs_manager := (auth.uid() <> t.user_id)
or (now() - t.created_at > make_interval(mins => window_min));
if needs_manager then
-- Caller must be a manager in this shop AND prove it with PIN.
if not app.has_role_in_shop(t.shop_id, 'manager') then
raise exception 'manager approval required to void this transaction';
end if;
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
raise exception 'manager PIN required and must be valid';
end if;
end if;
-- Apply the void (only allowed via this function thanks to the guard).
perform set_config('app.txn_internal', 'on', true);
update app.transactions
set status = 'voided',
voided_at = now(),
voided_by = auth.uid(),
void_reason = p_reason,
void_approved_by = case when needs_manager then auth.uid() else null end
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
-- Recompute the row's hash so the chain reflects the new state.
perform set_config('app.txn_internal', 'on', true);
update app.transactions tt
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
perform app.log_auth_event('txn_voided', t.shop_id, null,
jsonb_build_object('txn_id', p_txn_id, 'manager_path', needs_manager));
end;
$$;
revoke all on function app.void_transaction(uuid, text, text) from public;
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
-- =====================================================================
-- Daily integrity checks (callable by an owner cron)
-- =====================================================================
create or replace function app.verify_chain(p_shop uuid)
returns table (txn_id uuid, reference_no bigint, ok boolean)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare prev bytea;
rec app.transactions%rowtype;
begin
if not app.has_role_in_shop(p_shop, 'owner')
and not app.has_role_in_shop(p_shop, 'auditor') then
raise exception 'not authorized';
end if;
prev := null;
for rec in
select * from app.transactions
where shop_id = p_shop
order by reference_no
loop
txn_id := rec.id;
reference_no := rec.reference_no;
ok := (rec.prev_row_hash is not distinct from prev)
and (rec.row_hash = app.txn_compute_hash(rec, prev));
prev := rec.row_hash;
return next;
end loop;
end;
$$;
revoke all on function app.verify_chain(uuid) from public;
grant execute on function app.verify_chain(uuid) to authenticated;
-- Reference-number gap detector
create or replace view app.v_reference_gaps as
select shop_id,
reference_no + 1 as gap_starts_at,
next_ref - 1 as gap_ends_at
from (
select shop_id, reference_no,
lead(reference_no) over (partition by shop_id order by reference_no) as next_ref
from app.transactions
) s
where next_ref is not null and next_ref <> reference_no + 1;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.transactions enable row level security;
alter table app.transactions force row level security;
alter table app.services enable row level security;
alter table app.services force row level security;
alter table app.shop_sequences enable row level security;
alter table app.shop_sequences force row level security;
alter table app.system_settings enable row level security;
alter table app.system_settings force row level security;
-- Direct UPDATE/DELETE blocked by triggers, but also revoke at SQL level.
revoke update, delete on app.transactions from authenticated;
revoke insert, update, delete on app.shop_sequences from authenticated;
revoke insert, update, delete on app.system_settings from authenticated;
drop policy if exists txn_select on app.transactions;
create policy txn_select on app.transactions
for select to authenticated
using (
user_id = auth.uid()
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
);
drop policy if exists txn_insert_in_open_shift on app.transactions;
create policy txn_insert_in_open_shift on app.transactions
for insert to authenticated
with check (
exists (
select 1 from app.shifts s
where s.id = transactions.shift_id
and s.status = 'open'
and s.user_id = auth.uid()
)
);
-- Services: readable by all authenticated users; only owners may modify
-- (via direct grants kept off, future migration will add a function).
drop policy if exists services_select on app.services;
create policy services_select on app.services
for select to authenticated using (true);
-- Shop sequences and system settings: readable by owner/auditor.
drop policy if exists shop_seq_select on app.shop_sequences;
create policy shop_seq_select on app.shop_sequences
for select to authenticated
using (app.has_any_role_in_shop(shop_id, array['owner','auditor']::app.business_role[]));
drop policy if exists settings_select on app.system_settings;
create policy settings_select on app.system_settings
for select to authenticated using (true);
grant select, insert on app.transactions to authenticated;
grant select on app.services to authenticated;
grant select on app.shop_sequences to authenticated;
grant select on app.system_settings to authenticated;
-- =====================================================================
-- Seed services
-- =====================================================================
insert into app.services(code, name, category) values
('OMT_SEND', 'OMT Send', 'transfer'),
('OMT_RECEIVE', 'OMT Receive/Payout', 'transfer'),
('OMT_BILL', 'OMT Bill Payment', 'bill'),
('WU_SEND', 'Western Union Send', 'transfer'),
('WU_RECEIVE', 'Western Union Pay', 'transfer'),
('ALFA_RECHARGE', 'Alfa Recharge', 'recharge'),
('TOUCH_RECHARGE', 'touch Recharge', 'recharge'),
('OGERO_RECHARGE', 'Ogero Recharge', 'recharge'),
('INTERNET_RECHARGE','Internet Recharge', 'recharge'),
('SIM_SALE', 'SIM Sale', 'goods'),
('PHONE_SALE', 'Phone Sale', 'goods'),
('ACCESSORY_SALE', 'Accessory Sale', 'goods'),
('REPAIR', 'Repair Service', 'service')
on conflict (code) do nothing;
-- End migration 0003 ----------------------------------------------------
@@ -0,0 +1,388 @@
-- =====================================================================
-- Migration 0004 — Service-specific detail tables (roadmap Step 5).
--
-- Each child row is 1-to-1 with a row in app.transactions and is
-- mandatory for its service. A check trigger blocks completing a txn of
-- a given service without the matching detail row.
--
-- Threat-model rows addressed: 1, 3, 5, 16, 19, 21.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Common helpers
-- ---------------------------------------------------------------------
do $$ begin
create type app.id_doc_type as enum (
'lebanese_id', 'passport', 'residence_permit', 'driver_license', 'other'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.transfer_direction as enum ('domestic', 'international');
exception when duplicate_object then null; end $$;
-- A small helper used by all child triggers: the parent txn must exist,
-- be 'completed' (we attach detail at insert time only), and match the
-- expected service_code.
create or replace function app._require_txn_service(p_txn uuid, p_service text)
returns void
language plpgsql
stable
as $$
declare svc text; st app.txn_status;
begin
select service_code, status into svc, st
from app.transactions where id = p_txn;
if svc is null then raise exception 'transaction % not found', p_txn; end if;
if svc <> p_service then
raise exception 'detail mismatch: txn service is % but detail row is for %',
svc, p_service;
end if;
if st <> 'completed' then
raise exception 'cannot attach detail to a % transaction', st;
end if;
end;
$$;
-- =====================================================================
-- OMT — Send
-- =====================================================================
create table if not exists app.omt_send_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
direction app.transfer_direction not null,
sender_full_name text not null,
sender_id_type app.id_doc_type not null,
sender_id_number text not null,
sender_phone text not null,
sender_dob date,
sender_nationality text,
beneficiary_full_name text not null,
beneficiary_phone text,
destination_country text, -- ISO-3166 alpha-2 expected for international
purpose_code text not null, -- 'family_support','salary','goods','services',...
purpose_note text,
kyc_doc_url text, -- ID photo / declaration
created_at timestamptz not null default now(),
constraint omt_send_intl_country_required check (
direction = 'domestic' or destination_country is not null
),
constraint omt_send_id_format check (length(btrim(sender_id_number)) >= 4)
);
-- =====================================================================
-- OMT — Receive / Payout
-- =====================================================================
create table if not exists app.omt_receive_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
payout_code text not null, -- the customer-presented code
beneficiary_full_name text not null,
beneficiary_id_type app.id_doc_type not null,
beneficiary_id_number text not null,
beneficiary_phone text,
origin_country text,
kyc_doc_url text,
created_at timestamptz not null default now(),
constraint omt_recv_id_format check (length(btrim(beneficiary_id_number)) >= 4),
constraint omt_recv_code_format check (length(btrim(payout_code)) >= 6)
);
-- =====================================================================
-- Bill payment (EDL, water, internet bills, gov fees, etc.)
-- =====================================================================
create table if not exists app.bill_payment_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
biller_code text not null, -- 'EDL','OGERO','MOF','NSSF',...
account_number text not null,
period text, -- '2026-04', invoice id, etc.
customer_name text,
created_at timestamptz not null default now(),
constraint bill_account_format check (length(btrim(account_number)) >= 3)
);
-- =====================================================================
-- Recharge (Alfa / touch / Ogero / Internet)
-- Either a voucher_serial (physical scratch card) OR an
-- e_recharge_provider_ref (provider confirmation id) is mandatory.
-- =====================================================================
create table if not exists app.recharge_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
operator text not null, -- 'ALFA','TOUCH','OGERO','IDM','CYBERIA','TERRANET'
msisdn text not null, -- subscriber number being recharged
product_code text not null, -- 'U-CARD-22USD','MAGIC-11USD','DATA-5GB',...
voucher_serial text, -- if scratch card
e_recharge_provider_ref text, -- if e-recharge
unit_face_value_usd numeric(14,2),
unit_cost_usd numeric(14,2), -- cost to shop (margin = price - cost)
created_at timestamptz not null default now(),
constraint recharge_msisdn_format check (msisdn ~ '^\+?\d{6,15}$'),
constraint recharge_must_have_evidence check (
(voucher_serial is not null) or (e_recharge_provider_ref is not null)
)
);
-- A given voucher serial may only ever be sold once across the whole
-- system (vector #4: skim a card, claim "lost").
create unique index if not exists uq_recharge_voucher_serial
on app.recharge_details(voucher_serial)
where voucher_serial is not null;
-- An e-recharge provider reference is unique per operator.
create unique index if not exists uq_recharge_provider_ref
on app.recharge_details(operator, e_recharge_provider_ref)
where e_recharge_provider_ref is not null;
create index if not exists idx_recharge_msisdn on app.recharge_details(msisdn);
create index if not exists idx_recharge_operator on app.recharge_details(operator);
-- =====================================================================
-- Goods sale (SIM / phone / accessory) and repair
-- Real inventory FK comes in 0006; for now we capture sku + qty.
-- =====================================================================
create table if not exists app.goods_sale_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
sku text not null,
qty integer not null check (qty > 0),
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
unit_price_usd numeric(14,2) not null check (unit_price_usd >= 0),
serial_number text, -- IMEI for phones
created_at timestamptz not null default now()
);
create table if not exists app.repair_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
device_type text not null,
device_imei text,
issue_summary text not null,
warranty_days integer not null default 0 check (warranty_days >= 0),
created_at timestamptz not null default now()
);
-- =====================================================================
-- Triggers — service consistency + append-only on details
-- =====================================================================
create or replace function app._detail_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction detail rows are append-only'; end;
$$;
-- per-table: stamp + service-code check + immutability
create or replace function app.omt_send_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_SEND'); return new; end;
$$;
drop trigger if exists trg_omt_send_check on app.omt_send_details;
create trigger trg_omt_send_check before insert on app.omt_send_details
for each row execute function app.omt_send_check();
drop trigger if exists trg_omt_send_freeze on app.omt_send_details;
create trigger trg_omt_send_freeze before update or delete on app.omt_send_details
for each row execute function app._detail_no_update_delete();
create or replace function app.omt_recv_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_RECEIVE'); return new; end;
$$;
drop trigger if exists trg_omt_recv_check on app.omt_receive_details;
create trigger trg_omt_recv_check before insert on app.omt_receive_details
for each row execute function app.omt_recv_check();
drop trigger if exists trg_omt_recv_freeze on app.omt_receive_details;
create trigger trg_omt_recv_freeze before update or delete on app.omt_receive_details
for each row execute function app._detail_no_update_delete();
create or replace function app.bill_pay_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_BILL'); return new; end;
$$;
drop trigger if exists trg_bill_pay_check on app.bill_payment_details;
create trigger trg_bill_pay_check before insert on app.bill_payment_details
for each row execute function app.bill_pay_check();
drop trigger if exists trg_bill_pay_freeze on app.bill_payment_details;
create trigger trg_bill_pay_freeze before update or delete on app.bill_payment_details
for each row execute function app._detail_no_update_delete();
-- Recharge: any of the four recharge service codes is acceptable.
create or replace function app.recharge_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('ALFA_RECHARGE','TOUCH_RECHARGE','OGERO_RECHARGE','INTERNET_RECHARGE') then
raise exception 'recharge_details only valid for recharge services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_recharge_check on app.recharge_details;
create trigger trg_recharge_check before insert on app.recharge_details
for each row execute function app.recharge_check();
drop trigger if exists trg_recharge_freeze on app.recharge_details;
create trigger trg_recharge_freeze before update or delete on app.recharge_details
for each row execute function app._detail_no_update_delete();
create or replace function app.goods_sale_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('SIM_SALE','PHONE_SALE','ACCESSORY_SALE') then
raise exception 'goods_sale_details only valid for goods services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_goods_sale_check on app.goods_sale_details;
create trigger trg_goods_sale_check before insert on app.goods_sale_details
for each row execute function app.goods_sale_check();
drop trigger if exists trg_goods_sale_freeze on app.goods_sale_details;
create trigger trg_goods_sale_freeze before update or delete on app.goods_sale_details
for each row execute function app._detail_no_update_delete();
create or replace function app.repair_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'REPAIR'); return new; end;
$$;
drop trigger if exists trg_repair_check on app.repair_details;
create trigger trg_repair_check before insert on app.repair_details
for each row execute function app.repair_check();
drop trigger if exists trg_repair_freeze on app.repair_details;
create trigger trg_repair_freeze before update or delete on app.repair_details
for each row execute function app._detail_no_update_delete();
-- =====================================================================
-- Cross-row check: a completed transaction must have its matching
-- detail row. Implemented as a deferred constraint trigger that fires
-- at COMMIT time on app.transactions, so client code can do
-- BEGIN; INSERT txn; INSERT detail; COMMIT;
-- =====================================================================
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;
else ok := true; -- unknown / future services: allow until a child is added
end case;
if not ok then
raise exception 'transaction % (service %) is missing its detail row',
new.id, new.service_code;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_require_detail on app.transactions;
create constraint trigger trg_txn_require_detail
after insert on app.transactions
deferrable initially deferred
for each row execute function app.txn_require_detail();
-- =====================================================================
-- RLS — visibility follows the parent transaction.
-- =====================================================================
alter table app.omt_send_details enable row level security;
alter table app.omt_receive_details enable row level security;
alter table app.bill_payment_details enable row level security;
alter table app.recharge_details enable row level security;
alter table app.goods_sale_details enable row level security;
alter table app.repair_details enable row level security;
alter table app.omt_send_details force row level security;
alter table app.omt_receive_details force row level security;
alter table app.bill_payment_details force row level security;
alter table app.recharge_details force row level security;
alter table app.goods_sale_details force row level security;
alter table app.repair_details force row level security;
revoke update, delete on
app.omt_send_details, app.omt_receive_details, app.bill_payment_details,
app.recharge_details, app.goods_sale_details, app.repair_details
from authenticated;
-- Helper: visibility predicate based on parent txn.
create or replace function app._can_see_txn(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
where t.id = p_txn
and (
t.user_id = auth.uid()
or app.has_any_role_in_shop(t.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
$$;
revoke all on function app._can_see_txn(uuid) from public;
grant execute on function app._can_see_txn(uuid) to authenticated;
-- Insert allowed if the user owns the parent txn's open shift.
create or replace function app._can_write_detail(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
join app.shifts s on s.id = t.shift_id
where t.id = p_txn
and t.user_id = auth.uid()
and s.status = 'open'
);
$$;
revoke all on function app._can_write_detail(uuid) from public;
grant execute on function app._can_write_detail(uuid) to authenticated;
-- Apply identical select/insert policies to all six child tables.
do $$
declare tbl text;
begin
foreach tbl in array array[
'omt_send_details','omt_receive_details','bill_payment_details',
'recharge_details','goods_sale_details','repair_details'
] loop
execute format('drop policy if exists %I_select on app.%I;', tbl, tbl);
execute format($p$
create policy %I_select on app.%I
for select to authenticated
using (app._can_see_txn(txn_id));
$p$, tbl, tbl);
execute format('drop policy if exists %I_insert on app.%I;', tbl, tbl);
execute format($p$
create policy %I_insert on app.%I
for insert to authenticated
with check (app._can_write_detail(txn_id));
$p$, tbl, tbl);
execute format('grant select, insert on app.%I to authenticated;', tbl);
end loop;
end $$;
-- End migration 0004 ----------------------------------------------------
@@ -0,0 +1,618 @@
-- =====================================================================
-- Migration 0005 — Inventory and e-float (roadmap Step 6).
--
-- Two parallel stock systems for a cell shop:
--
-- 1. Physical inventory: scratch cards (with serials), SIMs, phones,
-- accessories. Voucher serials track per-card lifecycle so the
-- same card can never be sold twice and "lost" cards are visible.
--
-- 2. Electronic float: OMT cash float, Alfa/touch e-recharge wallet,
-- whish, etc. Every recharge or transfer must move e-float in
-- lockstep with cash, otherwise reconciliation fails.
--
-- Threat-model rows addressed: 3, 4, 5, 13, 21, 22.
-- =====================================================================
-- =====================================================================
-- Items and physical stock
-- =====================================================================
do $$ begin
create type app.item_type as enum (
'scratch_card', 'sim', 'phone', 'accessory', 'consumable'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.stock_movement_type as enum (
'purchase_in', -- received from distributor
'sale_out', -- linked to a transaction
'return_in', -- customer return
'damaged_out', -- write-off (manager approval)
'lost_out', -- write-off (manager approval)
'transfer_in', -- between shops
'transfer_out',
'adjustment_in', -- audited correction
'adjustment_out'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.voucher_status as enum (
'in_stock', 'sold', 'damaged', 'lost', 'returned'
);
exception when duplicate_object then null; end $$;
create table if not exists app.items (
sku text primary key,
name text not null,
type app.item_type not null,
operator text, -- 'ALFA','TOUCH', null for non-recharge
face_value_usd numeric(14,2), -- recharge denomination if applicable
cost_usd numeric(14,2) not null check (cost_usd >= 0),
price_usd numeric(14,2) not null check (price_usd >= 0),
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Per-shop stock-on-hand counter (denormalized, kept in sync by trigger).
create table if not exists app.stock_on_hand (
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
qty integer not null default 0 check (qty >= 0),
primary key (sku, shop_id)
);
create table if not exists app.stock_lots (
id uuid primary key default gen_random_uuid(),
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
received_at timestamptz not null default now(),
qty_received integer not null check (qty_received > 0),
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
supplier text,
invoice_no text,
received_by uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now()
);
create index if not exists idx_stock_lots_sku_shop on app.stock_lots(sku, shop_id);
-- Append-only stock movements ledger -----------------------------------
create table if not exists app.stock_movements (
id uuid primary key default gen_random_uuid(),
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
shift_id uuid references app.shifts(id),
type app.stock_movement_type not null,
-- Signed: positive = +stock (purchase_in, return_in, transfer_in, adjustment_in)
-- negative = -stock (sale_out, damaged_out, lost_out, transfer_out, adjustment_out)
qty_delta integer not null check (qty_delta <> 0),
ref_txn_id uuid references app.transactions(id),
ref_lot_id uuid references app.stock_lots(id),
approved_by uuid references auth.users(id), -- required for damaged/lost/adjustment
reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_stock_mov_sku_shop on app.stock_movements(sku, shop_id, created_at desc);
create index if not exists idx_stock_mov_txn on app.stock_movements(ref_txn_id);
-- =====================================================================
-- Voucher inventory (per-serial lifecycle)
-- =====================================================================
create table if not exists app.voucher_inventory (
serial text primary key,
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
lot_id uuid references app.stock_lots(id),
status app.voucher_status not null default 'in_stock',
received_at timestamptz not null default now(),
sold_txn_id uuid references app.transactions(id),
sold_at timestamptz,
status_changed_by uuid references auth.users(id),
status_change_reason text,
constraint voucher_status_consistency check (
(status = 'in_stock' and sold_txn_id is null and sold_at is null)
or (status = 'sold' and sold_txn_id is not null and sold_at is not null)
or (status in ('damaged','lost','returned')
and sold_txn_id is null and sold_at is null)
)
);
create index if not exists idx_voucher_status on app.voucher_inventory(status);
create index if not exists idx_voucher_sku_shop on app.voucher_inventory(sku, shop_id);
-- =====================================================================
-- E-float (OMT cash float, Alfa e-recharge wallet, etc.)
-- =====================================================================
do $$ begin
create type app.float_provider as enum (
'OMT_CASH', 'OMT_DIGITAL', 'ALFA_ERECHARGE', 'TOUCH_ERECHARGE',
'OGERO_ERECHARGE', 'WHISH', 'CARD_TERMINAL', 'BANK'
);
exception when duplicate_object then null; end $$;
create table if not exists app.floats (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
provider app.float_provider not null,
currency app.currency_code not null,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (shop_id, provider, currency)
);
-- Cached balance per float, kept in sync by the movements trigger.
create table if not exists app.float_balances (
float_id uuid primary key references app.floats(id) on delete cascade,
balance numeric(20,2) not null default 0,
updated_at timestamptz not null default now()
);
create table if not exists app.float_movements (
id uuid primary key default gen_random_uuid(),
float_id uuid not null references app.floats(id) on delete restrict,
shift_id uuid references app.shifts(id),
occurred_at timestamptz not null default now(),
-- Signed: + adds to e-float, - removes from it.
amount numeric(20,2) not null check (amount <> 0),
ref_txn_id uuid references app.transactions(id),
ref_settlement_id uuid, -- FK added in 0007
reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_float_mov_float on app.float_movements(float_id, occurred_at);
create index if not exists idx_float_mov_txn on app.float_movements(ref_txn_id);
-- =====================================================================
-- Triggers — append-only, balance maintenance, no negative stock
-- =====================================================================
-- Stock movements: append-only.
create or replace function app._stock_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'stock_movements is append-only'; end;
$$;
drop trigger if exists trg_stock_mov_freeze on app.stock_movements;
create trigger trg_stock_mov_freeze before update or delete on app.stock_movements
for each row execute function app._stock_mov_no_update_delete();
-- Stock movements: server-stamped, sign matches type, optional manager
-- approval enforced for write-offs.
create or replace function app._stock_mov_before_insert()
returns trigger language plpgsql as $$
begin
new.created_at := now();
new.created_by := auth.uid();
-- Sign / type consistency.
if new.type in ('purchase_in','return_in','transfer_in','adjustment_in')
and new.qty_delta <= 0 then
raise exception '% must have qty_delta > 0', new.type;
end if;
if new.type in ('sale_out','damaged_out','lost_out','transfer_out','adjustment_out')
and new.qty_delta >= 0 then
raise exception '% must have qty_delta < 0', new.type;
end if;
-- Write-offs and adjustments need manager approval.
if new.type in ('damaged_out','lost_out','adjustment_in','adjustment_out')
and new.approved_by is null then
raise exception '% requires manager approval (approved_by)', new.type;
end if;
-- sale_out must reference a real, completed sale of the same shop.
if new.type = 'sale_out' then
if new.ref_txn_id is null then
raise exception 'sale_out requires ref_txn_id';
end if;
if not exists (
select 1 from app.transactions
where id = new.ref_txn_id and shop_id = new.shop_id and status = 'completed'
) then
raise exception 'sale_out must reference a completed txn in the same shop';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_stock_mov_before_insert on app.stock_movements;
create trigger trg_stock_mov_before_insert before insert on app.stock_movements
for each row execute function app._stock_mov_before_insert();
-- Maintain stock_on_hand. No negative balance allowed.
create or replace function app._stock_on_hand_apply()
returns trigger language plpgsql as $$
begin
insert into app.stock_on_hand(sku, shop_id, qty)
values (new.sku, new.shop_id, new.qty_delta)
on conflict (sku, shop_id) do update
set qty = app.stock_on_hand.qty + new.qty_delta;
-- Re-check; the CHECK on the table will already reject negatives but
-- give a clearer error here.
if (select qty from app.stock_on_hand
where sku = new.sku and shop_id = new.shop_id) < 0 then
raise exception 'stock would go negative for sku=% shop=%', new.sku, new.shop_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_stock_on_hand_apply on app.stock_movements;
create trigger trg_stock_on_hand_apply after insert on app.stock_movements
for each row execute function app._stock_on_hand_apply();
-- Stock_lots: receiving stock auto-creates a purchase_in movement.
create or replace function app._stock_lot_after_insert()
returns trigger language plpgsql as $$
begin
insert into app.stock_movements(sku, shop_id, type, qty_delta, ref_lot_id, reason)
values (new.sku, new.shop_id, 'purchase_in', new.qty_received, new.id,
coalesce('lot ' || new.invoice_no, 'lot received'));
return null;
end;
$$;
drop trigger if exists trg_stock_lot_after_insert on app.stock_lots;
create trigger trg_stock_lot_after_insert after insert on app.stock_lots
for each row execute function app._stock_lot_after_insert();
-- Float movements: append-only + balance.
create or replace function app._float_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'float_movements is append-only'; end;
$$;
drop trigger if exists trg_float_mov_freeze on app.float_movements;
create trigger trg_float_mov_freeze before update or delete on app.float_movements
for each row execute function app._float_mov_no_update_delete();
create or replace function app._float_balance_apply()
returns trigger language plpgsql as $$
begin
insert into app.float_balances(float_id, balance, updated_at)
values (new.float_id, new.amount, now())
on conflict (float_id) do update
set balance = app.float_balances.balance + new.amount,
updated_at = now();
if (select balance from app.float_balances where float_id = new.float_id) < 0 then
raise exception 'float would go negative for float_id=%', new.float_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_float_balance_apply on app.float_movements;
create trigger trg_float_balance_apply after insert on app.float_movements
for each row execute function app._float_balance_apply();
-- =====================================================================
-- Recharge ↔ stock/float coupling
-- A recharge_details row MUST move either physical stock (voucher) or
-- e-float, otherwise it is a free recharge — exactly the fraud we want
-- to make impossible (vectors #3, #21).
-- Implemented as a deferred constraint trigger so the client can write
-- the recharge row first, then the movement, in a single transaction.
-- =====================================================================
create or replace function app._recharge_require_movement()
returns trigger
language plpgsql
as $$
declare
has_voucher_movement boolean;
has_float_movement boolean;
v_provider app.float_provider;
begin
if new.voucher_serial is not null then
-- The voucher must be marked sold and tied to this txn.
select exists(
select 1 from app.voucher_inventory
where serial = new.voucher_serial
and status = 'sold'
and sold_txn_id = new.txn_id
) into has_voucher_movement;
if not has_voucher_movement then
raise exception
'recharge with voucher_serial=% must be paired with a sold voucher',
new.voucher_serial;
end if;
else
-- E-recharge: an e-float debit must exist for this txn against the
-- matching operator's e-float account.
v_provider := case new.operator
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
else null
end;
if v_provider is null then
-- Unmapped operator (IDM, CYBERIA, TERRANET): require any negative
-- float movement for this txn.
select exists(
select 1 from app.float_movements
where ref_txn_id = new.txn_id and amount < 0
) into has_float_movement;
else
select exists(
select 1
from app.float_movements fm
join app.floats f on f.id = fm.float_id
join app.transactions t on t.id = fm.ref_txn_id
where fm.ref_txn_id = new.txn_id
and fm.amount < 0
and f.provider = v_provider
and f.shop_id = t.shop_id
) into has_float_movement;
end if;
if not has_float_movement then
raise exception
'e-recharge txn % must be paired with a negative e-float movement',
new.txn_id;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_recharge_require_movement on app.recharge_details;
create constraint trigger trg_recharge_require_movement
after insert on app.recharge_details
deferrable initially deferred
for each row execute function app._recharge_require_movement();
-- Goods sale ↔ stock_movement coupling (same idea).
create or replace function app._goods_sale_require_movement()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
select exists(
select 1 from app.stock_movements sm
where sm.ref_txn_id = new.txn_id
and sm.sku = new.sku
and sm.type = 'sale_out'
and -sm.qty_delta = new.qty
) into ok;
if not ok then
raise exception 'goods sale txn % must be paired with a sale_out stock movement', new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_movement on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_movement
after insert on app.goods_sale_details
deferrable initially deferred
for each row execute function app._goods_sale_require_movement();
-- =====================================================================
-- SECURITY DEFINER helpers used by the cashier UI
-- =====================================================================
-- Sell a scratch card: marks the voucher sold + creates the stock_out.
-- Called inside the same transaction as inserting the txn + recharge_details.
create or replace function app.sell_voucher(
p_txn_id uuid,
p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
t app.transactions%rowtype;
begin
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.user_id <> auth.uid() then
raise exception 'only the txn owner may sell a voucher against it';
end if;
select * into v from app.voucher_inventory where serial = p_serial for update;
if v.serial is null then raise exception 'voucher % not found', p_serial; end if;
if v.shop_id <> t.shop_id then
raise exception 'voucher belongs to a different shop';
end if;
if v.status <> 'in_stock' then
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
end if;
update app.voucher_inventory
set status = 'sold', sold_txn_id = p_txn_id, sold_at = now(),
status_changed_by = auth.uid()
where serial = p_serial;
insert into app.stock_movements(sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason)
values (v.sku, v.shop_id, t.shift_id, 'sale_out', -1, p_txn_id, 'voucher ' || p_serial);
end;
$$;
-- Mark a voucher damaged or lost (manager only, with PIN).
create or replace function app.write_off_voucher(
p_serial text,
p_status app.voucher_status,
p_reason text,
p_manager_pin text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
begin
if p_status not in ('damaged','lost') then
raise exception 'only damaged/lost are valid write-off statuses';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into v from app.voucher_inventory where serial = p_serial for update;
if v.serial is null then raise exception 'voucher not found'; end if;
if v.status <> 'in_stock' then
raise exception 'voucher must be in_stock to write off (was %)', v.status;
end if;
if not app.has_role_in_shop(v.shop_id, 'manager') then
raise exception 'manager role required';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
update app.voucher_inventory
set status = p_status, status_changed_by = auth.uid(),
status_change_reason = p_reason
where serial = p_serial;
insert into app.stock_movements(sku, shop_id, type, qty_delta, approved_by, reason)
values (v.sku, v.shop_id,
case p_status when 'damaged' then 'damaged_out'::app.stock_movement_type
when 'lost' then 'lost_out'::app.stock_movement_type end,
-1, auth.uid(), p_reason);
end;
$$;
revoke all on function app.sell_voucher(uuid, text) from public;
revoke all on function app.write_off_voucher(text, app.voucher_status, text, text) from public;
grant execute on function app.sell_voucher(uuid, text) to authenticated;
grant execute on function app.write_off_voucher(text, app.voucher_status, text, text) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.items enable row level security;
alter table app.stock_on_hand enable row level security;
alter table app.stock_lots enable row level security;
alter table app.stock_movements enable row level security;
alter table app.voucher_inventory enable row level security;
alter table app.floats enable row level security;
alter table app.float_balances enable row level security;
alter table app.float_movements enable row level security;
alter table app.items force row level security;
alter table app.stock_on_hand force row level security;
alter table app.stock_lots force row level security;
alter table app.stock_movements force row level security;
alter table app.voucher_inventory force row level security;
alter table app.floats force row level security;
alter table app.float_balances force row level security;
alter table app.float_movements force row level security;
-- Block direct UPDATE/DELETE on append-only tables.
revoke update, delete on app.stock_movements from authenticated;
revoke update, delete on app.float_movements from authenticated;
revoke update, delete on app.voucher_inventory from authenticated;
revoke update, delete on app.stock_on_hand from authenticated;
revoke update, delete on app.float_balances from authenticated;
-- Items are reference data: only owners may modify (handled by policy).
-- Items: readable by everyone authenticated; writes for owners only.
drop policy if exists items_select on app.items;
create policy items_select on app.items for select to authenticated using (true);
drop policy if exists items_write_owner on app.items;
create policy items_write_owner on app.items
for all to authenticated
using (app.is_owner_anywhere())
with check (app.is_owner_anywhere());
-- Shop-scoped tables: readable to anyone assigned to the shop.
drop policy if exists soh_select on app.stock_on_hand;
create policy soh_select on app.stock_on_hand
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists lots_select on app.stock_lots;
create policy lots_select on app.stock_lots
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
drop policy if exists lots_insert on app.stock_lots;
create policy lots_insert on app.stock_lots
for insert to authenticated
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.stock_lots to authenticated;
drop policy if exists smov_select on app.stock_movements;
create policy smov_select on app.stock_movements
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists smov_insert on app.stock_movements;
create policy smov_insert on app.stock_movements
for insert to authenticated
with check (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier']::app.business_role[])
);
grant select, insert on app.stock_movements to authenticated;
drop policy if exists vouch_select on app.voucher_inventory;
create policy vouch_select on app.voucher_inventory
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists vouch_insert on app.voucher_inventory;
create policy vouch_insert on app.voucher_inventory
for insert to authenticated
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.voucher_inventory to authenticated;
-- Voucher status changes go through SECURITY DEFINER functions only.
drop policy if exists floats_select on app.floats;
create policy floats_select on app.floats
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists floats_write_owner on app.floats;
create policy floats_write_owner on app.floats
for all to authenticated
using (app.has_role_in_shop(shop_id, 'owner'))
with check (app.has_role_in_shop(shop_id, 'owner'));
grant select, insert, update on app.floats to authenticated;
drop policy if exists fbal_select on app.float_balances;
create policy fbal_select on app.float_balances
for select to authenticated
using (
exists (
select 1 from app.floats f
where f.id = float_balances.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
)
);
drop policy if exists fmov_select on app.float_movements;
create policy fmov_select on app.float_movements
for select to authenticated
using (
exists (
select 1 from app.floats f
where f.id = float_movements.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
)
);
drop policy if exists fmov_insert on app.float_movements;
create policy fmov_insert on app.float_movements
for insert to authenticated
with check (
exists (
select 1 from app.floats f
where f.id = float_movements.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier']::app.business_role[])
)
);
grant select, insert on app.float_movements to authenticated;
grant select on app.items, app.stock_on_hand, app.float_balances to authenticated;
-- End migration 0005 ----------------------------------------------------
@@ -0,0 +1,376 @@
-- =====================================================================
-- Migration 0006 — Customers, KYC, and AML controls (roadmap Step 7).
--
-- Builds the customer/KYC layer that backs OMT send/receive and any
-- transfer above thresholds. Aggregation views detect structuring
-- (splitting a large transfer across multiple smaller ones).
--
-- Threat-model rows addressed: 1, 5, 16.
-- =====================================================================
-- =====================================================================
-- Customers
-- =====================================================================
create table if not exists app.customers (
id uuid primary key default gen_random_uuid(),
full_name text not null,
id_type app.id_doc_type not null,
id_number text not null,
dob date,
nationality text,
phone text,
address text,
pep_flag boolean not null default false, -- politically exposed person
sanctions_hit boolean not null default false,
sanctions_checked_at timestamptz,
sanctions_source text, -- which list / API
notes text,
is_blocked boolean not null default false, -- owner can hard-block a customer
blocked_reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id),
constraint customers_id_unique unique (id_type, id_number)
);
create index if not exists idx_customers_phone on app.customers(phone);
create index if not exists idx_customers_name on app.customers(lower(full_name));
-- Stamp updated_*
create or replace function app._customers_stamp()
returns trigger language plpgsql as $$
begin
if tg_op = 'INSERT' then
new.created_by := auth.uid();
new.created_at := now();
end if;
new.updated_by := auth.uid();
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists trg_customers_stamp on app.customers;
create trigger trg_customers_stamp
before insert or update on app.customers
for each row execute function app._customers_stamp();
-- KYC documents (ID photos, declarations) -----------------------------
create table if not exists app.customer_documents (
id uuid primary key default gen_random_uuid(),
customer_id uuid not null references app.customers(id) on delete restrict,
doc_type text not null, -- 'id_front','id_back','passport','declaration'
file_url text not null,
uploaded_at timestamptz not null default now(),
uploaded_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_customer_docs on app.customer_documents(customer_id);
-- Append-only customer-document table.
create or replace function app._customer_docs_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'customer_documents is append-only'; end;
$$;
drop trigger if exists trg_customer_docs_freeze on app.customer_documents;
create trigger trg_customer_docs_freeze before update or delete on app.customer_documents
for each row execute function app._customer_docs_no_update_delete();
-- =====================================================================
-- Now that customers exists, attach the deferred FK from transactions.
-- =====================================================================
alter table app.transactions
drop constraint if exists transactions_customer_fk;
alter table app.transactions
add constraint transactions_customer_fk
foreign key (customer_id) references app.customers(id) on delete restrict;
-- =====================================================================
-- KYC thresholds (per service / currency). Server-controlled.
-- A txn at or above `daily_amount_warn` requires a customer record;
-- at or above `daily_amount_block` it is hard-blocked unless an owner
-- override is on file.
-- =====================================================================
create table if not exists app.kyc_thresholds (
service_code text not null references app.services(code),
currency app.currency_code not null,
daily_amount_warn numeric(18,2) not null check (daily_amount_warn > 0),
daily_amount_block numeric(18,2) not null check (daily_amount_block > 0),
primary key (service_code, currency),
constraint kyc_thresholds_order check (daily_amount_block >= daily_amount_warn)
);
-- Sensible defaults. Owners can edit later.
insert into app.kyc_thresholds(service_code, currency, daily_amount_warn, daily_amount_block) values
('OMT_SEND', 'USD', 500, 10000),
('OMT_SEND', 'LBP', 45000000, 900000000),
('OMT_RECEIVE', 'USD', 500, 10000),
('OMT_RECEIVE', 'LBP', 45000000, 900000000),
('WU_SEND', 'USD', 500, 10000),
('WU_RECEIVE', 'USD', 500, 10000)
on conflict (service_code, currency) do nothing;
-- =====================================================================
-- Aggregation helper: customer's running daily total in a service
-- across the network (all shops).
-- =====================================================================
create or replace function app.customer_daily_total(
p_customer uuid,
p_service text,
p_currency app.currency_code,
p_at timestamptz default now()
) returns numeric
language sql
security definer
set search_path = app, public
stable
as $$
select coalesce(sum(
case when p_currency = 'USD' then t.gross_usd else t.gross_lbp end
), 0)
from app.transactions t
where t.customer_id = p_customer
and t.service_code = p_service
and t.status = 'completed'
and t.occurred_at >= date_trunc('day', p_at)
and t.occurred_at < date_trunc('day', p_at) + interval '1 day';
$$;
revoke all on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) from public;
grant execute on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) to authenticated;
-- =====================================================================
-- KYC enforcement: check at transaction insert.
-- For OMT/WU services, if the txn amount alone or the customer's
-- running daily total crosses warn → customer mandatory; crosses block
-- → reject unless an owner override row is in place for the day.
-- =====================================================================
create table if not exists app.kyc_overrides (
customer_id uuid not null references app.customers(id),
service_code text not null references app.services(code),
valid_for_day date not null,
approved_by uuid not null references auth.users(id),
reason text not null,
created_at timestamptz not null default now(),
primary key (customer_id, service_code, valid_for_day)
);
create or replace function app._txn_enforce_kyc()
returns trigger
language plpgsql
as $$
declare
th app.kyc_thresholds%rowtype;
amount_usd numeric := new.gross_usd;
amount_lbp numeric := new.gross_lbp;
daily_usd numeric := 0;
daily_lbp numeric := 0;
c app.customers%rowtype;
begin
if new.service_code not in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
return new;
end if;
-- USD branch
select * into th from app.kyc_thresholds
where service_code = new.service_code and currency = 'USD';
if found and amount_usd > 0 then
if new.customer_id is not null then
daily_usd := app.customer_daily_total(new.customer_id, new.service_code, 'USD', new.occurred_at);
end if;
if amount_usd + daily_usd >= th.daily_amount_warn and new.customer_id is null then
raise exception 'KYC: customer record required at or above % USD/day for %',
th.daily_amount_warn, new.service_code;
end if;
if amount_usd + daily_usd >= th.daily_amount_block then
if new.customer_id is null
or not exists (
select 1 from app.kyc_overrides
where customer_id = new.customer_id
and service_code = new.service_code
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
) then
raise exception 'KYC block: % USD/day exceeded for % (owner override required)',
th.daily_amount_block, new.service_code;
end if;
end if;
end if;
-- LBP branch
select * into th from app.kyc_thresholds
where service_code = new.service_code and currency = 'LBP';
if found and amount_lbp > 0 then
if new.customer_id is not null then
daily_lbp := app.customer_daily_total(new.customer_id, new.service_code, 'LBP', new.occurred_at);
end if;
if amount_lbp + daily_lbp >= th.daily_amount_warn and new.customer_id is null then
raise exception 'KYC: customer record required at or above % LBP/day for %',
th.daily_amount_warn, new.service_code;
end if;
if amount_lbp + daily_lbp >= th.daily_amount_block then
if new.customer_id is null
or not exists (
select 1 from app.kyc_overrides
where customer_id = new.customer_id
and service_code = new.service_code
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
) then
raise exception 'KYC block: % LBP/day exceeded for % (owner override required)',
th.daily_amount_block, new.service_code;
end if;
end if;
end if;
-- Hard-blocked / sanctioned customers are never allowed.
if new.customer_id is not null then
select * into c from app.customers where id = new.customer_id;
if c.is_blocked then
raise exception 'customer is blocked: %', coalesce(c.blocked_reason, 'no reason');
end if;
if c.sanctions_hit then
raise exception 'customer is on a sanctions list; transaction refused';
end if;
end if;
return new;
end;
$$;
-- Run KYC checks after the txn_before_insert trigger has populated
-- shop_id/till_id/user_id/reference_no.
drop trigger if exists trg_txn_enforce_kyc on app.transactions;
create trigger trg_txn_enforce_kyc
before insert on app.transactions
for each row execute function app._txn_enforce_kyc();
-- =====================================================================
-- Structuring detection (vector #16):
-- A customer running a high cumulative OMT total via repeated small
-- transfers, or the same beneficiary phone receiving from many cashiers
-- in a short window. Exposed as views for the AML dashboard.
-- =====================================================================
create or replace view app.v_aml_structuring_by_customer as
with d as (
select t.customer_id,
t.service_code,
(t.occurred_at at time zone 'UTC')::date as day,
count(*) as txn_count,
sum(t.gross_usd) as total_usd,
sum(t.gross_lbp) as total_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
and t.customer_id is not null
group by 1,2,3
)
select d.*,
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD') as warn_usd,
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP') as warn_lbp
from d
where d.txn_count >= 3 -- 3+ same-customer txns
and (
(d.total_usd >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD'), 1e18))
or (d.total_lbp >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP'), 1e18))
);
create or replace view app.v_aml_same_beneficiary_burst as
select beneficiary_phone,
date_trunc('hour', occurred_at) as hour_bucket,
count(*) as txn_count,
count(distinct user_id) as distinct_cashiers,
sum(gross_usd) as total_usd,
sum(gross_lbp) as total_lbp
from app.transactions
where status = 'completed'
and service_code in ('OMT_SEND','WU_SEND')
and beneficiary_phone is not null
group by 1,2
having count(*) >= 3
and count(distinct user_id) >= 2;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.customers enable row level security;
alter table app.customer_documents enable row level security;
alter table app.kyc_thresholds enable row level security;
alter table app.kyc_overrides enable row level security;
alter table app.customers force row level security;
alter table app.customer_documents force row level security;
alter table app.kyc_thresholds force row level security;
alter table app.kyc_overrides force row level security;
-- Customer rows: visible to anyone authenticated who actively uses
-- the system (cashiers need to find existing customers). Writes are
-- limited; deletion never permitted.
revoke delete on app.customers from authenticated;
revoke update, delete on app.customer_documents from authenticated;
revoke insert, update, delete on app.kyc_thresholds from authenticated;
revoke update, delete on app.kyc_overrides from authenticated;
drop policy if exists customers_select on app.customers;
create policy customers_select on app.customers
for select to authenticated using (true);
drop policy if exists customers_insert on app.customers;
create policy customers_insert on app.customers
for insert to authenticated
with check (auth.uid() is not null);
-- Restrict updates: cashiers may patch contact info; only owners may
-- toggle pep_flag, sanctions_hit, is_blocked. Enforced by trigger.
create or replace function app._customers_update_guard()
returns trigger language plpgsql as $$
begin
if not app.is_owner_anywhere() then
if new.pep_flag is distinct from old.pep_flag
or new.sanctions_hit is distinct from old.sanctions_hit
or new.is_blocked is distinct from old.is_blocked
or coalesce(new.blocked_reason,'') <> coalesce(old.blocked_reason,'') then
raise exception 'only an owner may change pep_flag, sanctions_hit, or is_blocked';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_customers_update_guard on app.customers;
create trigger trg_customers_update_guard
before update on app.customers
for each row execute function app._customers_update_guard();
drop policy if exists customers_update on app.customers;
create policy customers_update on app.customers
for update to authenticated
using (auth.uid() is not null)
with check (auth.uid() is not null);
drop policy if exists customer_docs_select on app.customer_documents;
create policy customer_docs_select on app.customer_documents
for select to authenticated using (true);
drop policy if exists customer_docs_insert on app.customer_documents;
create policy customer_docs_insert on app.customer_documents
for insert to authenticated
with check (auth.uid() is not null);
grant select, insert on app.customer_documents to authenticated;
drop policy if exists kyc_thr_select on app.kyc_thresholds;
create policy kyc_thr_select on app.kyc_thresholds
for select to authenticated using (true);
-- thresholds are owner-only; until an owner-edit function lands, only
-- DBA can change them.
drop policy if exists kyc_ovr_select on app.kyc_overrides;
create policy kyc_ovr_select on app.kyc_overrides
for select to authenticated
using (app.is_owner_anywhere() or approved_by = auth.uid());
drop policy if exists kyc_ovr_insert on app.kyc_overrides;
create policy kyc_ovr_insert on app.kyc_overrides
for insert to authenticated
with check (app.is_owner_anywhere() and approved_by = auth.uid());
grant select, insert on app.kyc_overrides to authenticated;
grant select, insert, update on app.customers to authenticated;
grant select on app.kyc_thresholds to authenticated;
-- End migration 0006 ----------------------------------------------------
@@ -0,0 +1,519 @@
-- =====================================================================
-- Migration 0007 — Receipts, signatures, evidence (roadmap Step 8).
--
-- Goals:
-- * Every receipt carries a server-signed token (HMAC-SHA256) so a
-- scanner / owner spot-check can verify it really came from this
-- system and was not printed by a side-printer or hand-edited
-- (vector #10).
-- * Customer notifications (SMS / email) are logged so the owner can
-- confirm that beneficiaries actually got their reference number,
-- exposing pocketed transactions (vector #1).
-- * Evidence (signature pad image, ID photo, voided-paper photo, OMT
-- POS slip scan) is attached append-only to a transaction.
--
-- Threat-model rows addressed: 1, 10, 11, 15.
-- =====================================================================
-- =====================================================================
-- HMAC secret
-- The signing key lives in app.system_secrets and is never returned to
-- clients (the `select` policy denies all non-DBA access). Functions
-- below are SECURITY DEFINER so they can read it.
-- =====================================================================
create table if not exists app.system_secrets (
key text primary key,
value text not null,
rotated_at timestamptz not null default now()
);
-- Generate an initial random key on first install. Owners should rotate
-- it via app.rotate_receipt_key() (added below) on a schedule.
insert into app.system_secrets(key, value)
values ('receipt_hmac_key', encode(gen_random_bytes(32), 'hex'))
on conflict (key) do nothing;
alter table app.system_secrets enable row level security;
alter table app.system_secrets force row level security;
revoke all on app.system_secrets from authenticated;
-- =====================================================================
-- Receipt token: HMAC over (txn_id || reference_no || shop_id || row_hash)
-- Embedded in the printed QR. Anyone holding a receipt + the public
-- verifier function can prove (or disprove) authenticity.
-- =====================================================================
create or replace function app._receipt_hmac_key()
returns bytea
language sql
security definer
set search_path = app, public
stable
as $$
select decode(value, 'hex') from app.system_secrets where key = 'receipt_hmac_key';
$$;
revoke all on function app._receipt_hmac_key() from public;
-- Not granted to anyone; only callable from inside other SECURITY DEFINER
-- functions in this schema.
create or replace function app.receipt_token(p_txn uuid)
returns text
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
t app.transactions%rowtype;
msg bytea;
sig bytea;
begin
select * into t from app.transactions where id = p_txn;
if t.id is null then raise exception 'txn not found'; end if;
-- Visibility check: caller must be allowed to see the txn.
if not app._can_see_txn(p_txn) then
raise exception 'not authorized';
end if;
msg := convert_to(
t.id::text || '|' || t.shop_id::text || '|' || t.reference_no::text
|| '|' || encode(t.row_hash, 'hex'),
'UTF8');
sig := hmac(msg, app._receipt_hmac_key(), 'sha256');
-- Token format: v1.<txn_id>.<reference_no>.<sig_b64>
return 'v1.' || t.id::text || '.' || t.reference_no::text || '.' ||
translate(encode(sig, 'base64'), E'+/=\n', '-_');
end;
$$;
revoke all on function app.receipt_token(uuid) from public;
grant execute on function app.receipt_token(uuid) to authenticated;
-- Public verifier: takes a token, returns the txn row + ok flag.
-- Anyone authenticated may call (so an owner can scan any receipt) but
-- the row is only returned if the signature checks out AND the caller
-- is allowed to see the txn under RLS.
create or replace function app.verify_receipt(p_token text)
returns table (
ok boolean,
txn_id uuid,
shop_id uuid,
reference_no bigint,
service_code text,
occurred_at timestamptz,
status app.txn_status
)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
parts text[];
v_txn uuid;
v_ref bigint;
v_sig_b64 text;
expected text;
t app.transactions%rowtype;
begin
parts := string_to_array(p_token, '.');
if array_length(parts, 1) <> 4 or parts[1] <> 'v1' then
ok := false; return next; return;
end if;
v_txn := parts[2]::uuid;
v_ref := parts[3]::bigint;
v_sig_b64 := parts[4];
select * into t from app.transactions where id = v_txn and reference_no = v_ref;
if t.id is null then
ok := false; return next; return;
end if;
expected := translate(
encode(
hmac(
convert_to(t.id::text || '|' || t.shop_id::text || '|' ||
t.reference_no::text || '|' || encode(t.row_hash, 'hex'), 'UTF8'),
app._receipt_hmac_key(), 'sha256'),
'base64'),
E'+/=\n', '-_');
if expected <> v_sig_b64 then
ok := false; return next; return;
end if;
if not app._can_see_txn(t.id) then
ok := false; return next; return;
end if;
ok := true;
txn_id := t.id;
shop_id := t.shop_id;
reference_no := t.reference_no;
service_code := t.service_code;
occurred_at := t.occurred_at;
status := t.status;
return next;
end;
$$;
revoke all on function app.verify_receipt(text) from public;
grant execute on function app.verify_receipt(text) to authenticated;
-- Key rotation (owner-only).
create or replace function app.rotate_receipt_key()
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.is_owner_anywhere() then
raise exception 'only an owner may rotate the receipt key';
end if;
update app.system_secrets
set value = encode(gen_random_bytes(32), 'hex'),
rotated_at = now()
where key = 'receipt_hmac_key';
perform app.log_auth_event('receipt_key_rotated', null, null, '{}'::jsonb);
end;
$$;
revoke all on function app.rotate_receipt_key() from public;
grant execute on function app.rotate_receipt_key() to authenticated;
-- =====================================================================
-- Receipts table: one row per print of a receipt (originals + reprints).
-- Append-only.
-- =====================================================================
do $$ begin
create type app.receipt_kind as enum ('original', 'reprint', 'duplicate');
exception when duplicate_object then null; end $$;
create table if not exists app.receipts (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
kind app.receipt_kind not null default 'original',
pdf_url text, -- server-rendered PDF
qr_token text not null, -- embedded HMAC token
printed_at timestamptz not null default now(),
printed_by uuid not null references auth.users(id) default auth.uid(),
device_fingerprint text
);
create index if not exists idx_receipts_txn on app.receipts(txn_id);
create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'receipts is append-only'; end;
$$;
drop trigger if exists trg_receipts_freeze on app.receipts;
create trigger trg_receipts_freeze before update or delete on app.receipts
for each row execute function app._receipts_no_update_delete();
-- =====================================================================
-- Customer notifications (SMS/email). Logged so an owner can confirm
-- the customer actually heard about the transaction.
-- =====================================================================
do $$ begin
create type app.notification_channel as enum ('sms','email','push');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.notification_status as enum
('queued','sent','delivered','failed');
exception when duplicate_object then null; end $$;
create table if not exists app.customer_notifications (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
channel app.notification_channel not null,
recipient text not null, -- phone or email
body_template text not null, -- 'omt_send_v1', 'recharge_v1', ...
status app.notification_status not null default 'queued',
provider_ref text, -- gateway message id
queued_at timestamptz not null default now(),
sent_at timestamptz,
delivered_at timestamptz,
failed_reason text,
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_notif_txn on app.customer_notifications(txn_id);
create index if not exists idx_notif_recipient on app.customer_notifications(recipient, queued_at desc);
create index if not exists idx_notif_status on app.customer_notifications(status);
-- Append-only except for status transitions, which only the gateway
-- (running as a dedicated DB role outside `authenticated`) may apply.
create or replace function app._notif_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'notifications cannot be deleted';
end if;
-- Allow status / timestamps / provider_ref / failed_reason updates.
if (new.id <> old.id
or new.txn_id <> old.txn_id
or new.channel <> old.channel
or new.recipient <> old.recipient
or new.body_template <> old.body_template
or new.queued_at <> old.queued_at
or new.created_by <> old.created_by) then
raise exception 'only delivery fields may change on a notification row';
end if;
return new;
end;
$$;
drop trigger if exists trg_notif_guard on app.customer_notifications;
create trigger trg_notif_guard before update or delete on app.customer_notifications
for each row execute function app._notif_guard();
-- =====================================================================
-- Evidence attachments (signatures, photos, ID scans).
-- Append-only. Visible to anyone who can see the parent txn.
-- =====================================================================
do $$ begin
create type app.evidence_kind as enum (
'customer_signature',
'id_photo',
'voided_paper_photo',
'omt_pos_slip',
'cancellation_photo',
'other'
);
exception when duplicate_object then null; end $$;
create table if not exists app.transaction_evidence (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
kind app.evidence_kind not null,
file_url text not null,
file_sha256 text, -- hex digest of stored bytes
note text,
uploaded_at timestamptz not null default now(),
uploaded_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_evidence_txn on app.transaction_evidence(txn_id, uploaded_at);
create or replace function app._evidence_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction_evidence is append-only'; end;
$$;
drop trigger if exists trg_evidence_freeze on app.transaction_evidence;
create trigger trg_evidence_freeze before update or delete on app.transaction_evidence
for each row execute function app._evidence_no_update_delete();
-- =====================================================================
-- High-value evidence policy (vector #15 — fake cancellations,
-- vector #11 — manager-approved void of a printed receipt):
-- a deferred constraint trigger enforces, at COMMIT, that:
-- * any voided txn whose original status was 'completed' has at
-- least one evidence row of kind 'voided_paper_photo'.
-- * any large OMT_SEND / OMT_RECEIVE has a 'customer_signature' or
-- 'id_photo' evidence row.
-- =====================================================================
create or replace function app._txn_require_evidence()
returns trigger language plpgsql as $$
declare
th_warn_usd numeric;
th_warn_lbp numeric;
has_sig boolean;
has_void boolean;
begin
-- Only check on UPDATE-to-voided or on relevant high-value services.
if tg_op = 'UPDATE' and new.status = 'voided' and old.status = 'completed' then
select exists(
select 1 from app.transaction_evidence
where txn_id = new.id and kind = 'voided_paper_photo'
) into has_void;
if not has_void then
raise exception 'void of txn % requires a voided_paper_photo evidence row', new.id;
end if;
end if;
if (tg_op = 'INSERT')
and new.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
select daily_amount_warn into th_warn_usd from app.kyc_thresholds
where service_code = new.service_code and currency = 'USD';
select daily_amount_warn into th_warn_lbp from app.kyc_thresholds
where service_code = new.service_code and currency = 'LBP';
if (new.gross_usd >= coalesce(th_warn_usd, 1e18))
or (new.gross_lbp >= coalesce(th_warn_lbp, 1e18)) then
select exists(
select 1 from app.transaction_evidence
where txn_id = new.id
and kind in ('customer_signature','id_photo','omt_pos_slip')
) into has_sig;
if not has_sig then
raise exception
'high-value % txn % requires customer_signature or id_photo evidence',
new.service_code, new.id;
end if;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_require_evidence on app.transactions;
create constraint trigger trg_txn_require_evidence
after insert or update on app.transactions
deferrable initially deferred
for each row execute function app._txn_require_evidence();
-- =====================================================================
-- Convenience: a SECURITY DEFINER `record_receipt_print` so the
-- printing service inside the app issues a fresh QR token and logs the
-- print in one go.
-- =====================================================================
create or replace function app.record_receipt_print(
p_txn_id uuid,
p_kind app.receipt_kind default 'original',
p_device text default null
)
returns table (receipt_id uuid, qr_token text, pdf_url text)
language plpgsql
security definer
set search_path = app, public
as $$
declare
tok text;
rid uuid;
begin
if not app._can_see_txn(p_txn_id) then
raise exception 'not authorized';
end if;
tok := app.receipt_token(p_txn_id);
insert into app.receipts(txn_id, kind, qr_token, device_fingerprint)
values (p_txn_id, p_kind, tok, p_device)
returning id into rid;
receipt_id := rid;
qr_token := tok;
pdf_url := null; -- the PDF rendering service will patch this
-- via record_receipt_pdf below.
return next;
end;
$$;
revoke all on function app.record_receipt_print(uuid, app.receipt_kind, text) from public;
grant execute on function app.record_receipt_print(uuid, app.receipt_kind, text) to authenticated;
-- The PDF renderer fills in pdf_url after upload to storage. The
-- `receipts` table is append-only via trigger, so we expose a tiny
-- definer function that allows just this one column update.
create or replace function app.record_receipt_pdf(
p_receipt_id uuid,
p_pdf_url text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
-- Allow direct UPDATE only via this function.
perform set_config('app.receipts_internal', 'on', true);
update app.receipts set pdf_url = p_pdf_url where id = p_receipt_id and pdf_url is null;
perform set_config('app.receipts_internal', 'off', true);
end;
$$;
revoke all on function app.record_receipt_pdf(uuid, text) from public;
grant execute on function app.record_receipt_pdf(uuid, text) to authenticated;
-- Adjust the receipts-freeze trigger to allow the definer path through.
create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'receipts cannot be deleted';
end if;
if current_setting('app.receipts_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on receipts is not allowed';
end if;
if (new.id <> old.id or new.txn_id <> old.txn_id or new.kind <> old.kind
or new.qr_token <> old.qr_token or new.printed_at <> old.printed_at
or new.printed_by <> old.printed_by) then
raise exception 'only pdf_url may change on a receipt row';
end if;
return new;
end;
$$;
-- =====================================================================
-- Customer-facing notification queue helper. The actual SMS gateway
-- (a worker process running as a dedicated role) will pick up rows
-- where status='queued' and update status to sent/delivered/failed.
-- =====================================================================
create or replace function app.queue_customer_notification(
p_txn uuid,
p_channel app.notification_channel,
p_recipient text,
p_template text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare nid uuid;
begin
if not app._can_see_txn(p_txn) then
raise exception 'not authorized';
end if;
insert into app.customer_notifications(txn_id, channel, recipient, body_template)
values (p_txn, p_channel, p_recipient, p_template)
returning id into nid;
return nid;
end;
$$;
revoke all on function app.queue_customer_notification(uuid, app.notification_channel, text, text) from public;
grant execute on function app.queue_customer_notification(uuid, app.notification_channel, text, text) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.receipts enable row level security;
alter table app.customer_notifications enable row level security;
alter table app.transaction_evidence enable row level security;
alter table app.receipts force row level security;
alter table app.customer_notifications force row level security;
alter table app.transaction_evidence force row level security;
revoke update, delete on app.receipts from authenticated;
revoke delete on app.customer_notifications from authenticated;
revoke update, delete on app.transaction_evidence from authenticated;
drop policy if exists receipts_select on app.receipts;
create policy receipts_select on app.receipts
for select to authenticated using (app._can_see_txn(txn_id));
drop policy if exists receipts_insert on app.receipts;
create policy receipts_insert on app.receipts
for insert to authenticated
with check (app._can_see_txn(txn_id));
grant select, insert on app.receipts to authenticated;
drop policy if exists notif_select on app.customer_notifications;
create policy notif_select on app.customer_notifications
for select to authenticated using (app._can_see_txn(txn_id));
drop policy if exists notif_insert on app.customer_notifications;
create policy notif_insert on app.customer_notifications
for insert to authenticated with check (app._can_see_txn(txn_id));
grant select, insert on app.customer_notifications to authenticated;
-- The gateway worker role gets UPDATE separately; not here.
drop policy if exists evidence_select on app.transaction_evidence;
create policy evidence_select on app.transaction_evidence
for select to authenticated using (app._can_see_txn(txn_id));
-- Evidence insert allowed for: shift owner during open shift OR any
-- manager/owner of the shop (so a manager can attach voided-paper
-- photos when approving a void after the cashier has closed shift).
drop policy if exists evidence_insert on app.transaction_evidence;
create policy evidence_insert on app.transaction_evidence
for insert to authenticated
with check (
exists (
select 1 from app.transactions t
join app.shifts s on s.id = t.shift_id
where t.id = transaction_evidence.txn_id
and (
(t.user_id = auth.uid() and s.status = 'open')
or app.has_any_role_in_shop(t.shop_id,
array['manager','owner']::app.business_role[])
)
)
);
grant select, insert on app.transaction_evidence to authenticated;
-- End migration 0007 ----------------------------------------------------
@@ -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 ----------------------------------------------------
@@ -0,0 +1,573 @@
-- =====================================================================
-- Migration 0009 — External reconciliation (roadmap Step 10).
--
-- The strongest fraud control is an external source of truth. Every
-- provider (OMT, Alfa, touch, Ogero, the bank, whish, the card terminal)
-- publishes a settlement statement; we import it line by line and match
-- each line to a local transaction by `external_ref`. Mismatches go to
-- `reconciliation_exceptions` and block month-close.
--
-- Threat-model rows addressed: 1, 5, 8, 21, 24.
-- =====================================================================
-- =====================================================================
-- Settlement headers + lines
-- =====================================================================
do $$ begin
create type app.settlement_provider as enum (
'OMT', 'ALFA', 'TOUCH', 'OGERO', 'WHISH', 'CARD_TERMINAL', 'BANK', 'WU'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_status as enum (
'imported', -- file parsed; matching not started
'matching', -- run is in progress
'matched', -- all lines matched, ready for sign-off
'has_exceptions', -- at least one line still unresolved
'closed' -- owner-signed off, immutable
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_line_status as enum (
'unmatched', -- no candidate found yet
'matched', -- exactly one candidate, amounts agree
'amount_mismatch', -- candidate found but money differs
'duplicate', -- the same external_ref already used elsewhere
'missing_local', -- provider has it; we don't
'extra_local' -- we have it; provider doesn't
);
exception when duplicate_object then null; end $$;
create table if not exists app.settlements (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
provider app.settlement_provider not null,
period_start date not null,
period_end date not null,
file_url text,
file_sha256 text,
total_amount numeric(20,2), -- as reported by provider
total_currency app.currency_code,
status app.settlement_status not null default 'imported',
imported_at timestamptz not null default now(),
imported_by uuid not null references auth.users(id) default auth.uid(),
closed_at timestamptz,
closed_by uuid references auth.users(id),
notes text,
constraint settlements_period_ok check (period_end >= period_start)
);
create index if not exists idx_settlements_shop_period
on app.settlements(shop_id, provider, period_start);
create table if not exists app.settlement_lines (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
-- Raw fields as parsed from the provider file:
external_ref text not null, -- provider txn id / receipt no
occurred_at timestamptz,
amount numeric(20,2) not null,
currency app.currency_code not null,
fee numeric(20,2),
commission numeric(20,2),
raw jsonb, -- the original parsed row
-- Match output:
matched_txn_id uuid references app.transactions(id),
status app.settlement_line_status not null default 'unmatched',
matched_at timestamptz,
-- A natural key per provider statement keeps imports idempotent.
unique (settlement_id, external_ref)
);
create index if not exists idx_settle_line_status on app.settlement_lines(settlement_id, status);
create index if not exists idx_settle_line_ref on app.settlement_lines(external_ref);
-- Exceptions queue. Every non-matched line generates a row here so the
-- owner has a single place to clear before closing the period.
create table if not exists app.reconciliation_exceptions (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
line_id uuid references app.settlement_lines(id) on delete cascade,
txn_id uuid references app.transactions(id),
type app.settlement_line_status not null,
detail text,
resolved_at timestamptz,
resolved_by uuid references auth.users(id),
resolution_note text,
created_at timestamptz not null default now()
);
create index if not exists idx_recon_exc_open
on app.reconciliation_exceptions(settlement_id) where resolved_at is null;
-- Late FK from float_movements (declared in 0005).
alter table app.float_movements
drop constraint if exists float_mov_settlement_fk;
alter table app.float_movements
add constraint float_mov_settlement_fk
foreign key (ref_settlement_id) references app.settlements(id) on delete restrict;
-- =====================================================================
-- Append-only behaviour where it matters
-- =====================================================================
-- Settlements: status moves are owner-driven via functions below.
create or replace function app._settlements_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'settlements cannot be deleted';
end if;
if old.status = 'closed' then
raise exception 'settlement % is closed and immutable', old.id;
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlements is not allowed; use app.* functions';
end if;
return new;
end;
$$;
drop trigger if exists trg_settlements_guard on app.settlements;
create trigger trg_settlements_guard before update or delete on app.settlements
for each row execute function app._settlements_guard();
-- Lines: insert at import time, matched in place by definer functions.
create or replace function app._settle_lines_guard()
returns trigger language plpgsql as $$
declare s app.settlements%rowtype;
begin
if tg_op = 'DELETE' then
raise exception 'settlement_lines cannot be deleted';
end if;
select * into s from app.settlements where id = coalesce(new.settlement_id, old.settlement_id);
if s.status = 'closed' then
raise exception 'cannot modify lines of a closed settlement';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlement_lines is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_settle_lines_guard on app.settlement_lines;
create trigger trg_settle_lines_guard before update or delete on app.settlement_lines
for each row execute function app._settle_lines_guard();
-- Exceptions: insert by matcher; resolution via definer.
create or replace function app._recon_exc_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'reconciliation_exceptions cannot be deleted';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on reconciliation_exceptions is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_recon_exc_guard on app.reconciliation_exceptions;
create trigger trg_recon_exc_guard before update or delete on app.reconciliation_exceptions
for each row execute function app._recon_exc_guard();
-- =====================================================================
-- Provider → service code map. Used by the matcher to know which local
-- service rows are eligible candidates for a given settlement file.
-- =====================================================================
create or replace function app._provider_service_codes(p app.settlement_provider)
returns text[]
language sql
immutable
as $$
select case p
when 'OMT' then array['OMT_SEND','OMT_RECEIVE','OMT_BILL']
when 'ALFA' then array['ALFA_RECHARGE']
when 'TOUCH' then array['TOUCH_RECHARGE']
when 'OGERO' then array['OGERO_RECHARGE','INTERNET_RECHARGE']
when 'WU' then array['WU_SEND','WU_RECEIVE']
-- BANK / WHISH / CARD_TERMINAL match by payment_method instead.
else null
end::text[];
$$;
-- =====================================================================
-- Import + match
-- =====================================================================
-- Insert one parsed row from the provider file. Idempotent by
-- (settlement_id, external_ref).
create or replace function app.add_settlement_line(
p_settlement uuid,
p_external_ref text,
p_occurred_at timestamptz,
p_amount numeric,
p_currency app.currency_code,
p_fee numeric,
p_commission numeric,
p_raw jsonb
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype; lid uuid;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then
raise exception 'settlement is closed';
end if;
insert into app.settlement_lines(settlement_id, external_ref, occurred_at,
amount, currency, fee, commission, raw)
values (p_settlement, p_external_ref, p_occurred_at,
p_amount, p_currency, p_fee, p_commission, p_raw)
on conflict (settlement_id, external_ref) do nothing
returning id into lid;
return lid;
end;
$$;
revoke all on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) from public;
grant execute on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) to authenticated;
-- Run the matcher across all unmatched lines of a settlement.
create or replace function app.run_match(p_settlement uuid)
returns table (matched int, exceptions int)
language plpgsql
security definer
set search_path = app, public
as $$
declare
s app.settlements%rowtype;
svc_codes text[];
ln app.settlement_lines%rowtype;
cand uuid;
cand_count int;
cand_amount_usd numeric;
cand_amount_lbp numeric;
cand_amount numeric;
ok_amount boolean;
m_count int := 0;
e_count int := 0;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then raise exception 'settlement is closed'; end if;
svc_codes := app._provider_service_codes(s.provider);
perform set_config('app.settle_internal', 'on', true);
update app.settlements set status = 'matching' where id = p_settlement;
for ln in
select * from app.settlement_lines
where settlement_id = p_settlement and status = 'unmatched'
loop
-- Find candidate(s) by external_ref + provider service codes (when
-- known) within the same shop, in completed state.
select count(*),
coalesce(min(t.id), null)
into cand_count, cand
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.external_ref = ln.external_ref
and (svc_codes is null or t.service_code = any(svc_codes));
if cand_count = 0 then
update app.settlement_lines
set status = 'missing_local'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'missing_local',
format('provider lists external_ref % but no local txn found', ln.external_ref));
e_count := e_count + 1;
elsif cand_count > 1 then
update app.settlement_lines
set status = 'duplicate'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'duplicate',
format('% local txns share external_ref %', cand_count, ln.external_ref));
e_count := e_count + 1;
else
-- Compare amounts within the matching currency. Allow 0.01 USD /
-- 100 LBP rounding tolerance.
select t.gross_usd, t.gross_lbp into cand_amount_usd, cand_amount_lbp
from app.transactions t where t.id = cand;
cand_amount := case ln.currency
when 'USD' then cand_amount_usd
when 'LBP' then cand_amount_lbp
end;
ok_amount := abs(coalesce(cand_amount,0) - coalesce(ln.amount,0))
<= case ln.currency when 'USD' then 0.01 else 100 end;
if ok_amount then
update app.settlement_lines
set status = 'matched',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
m_count := m_count + 1;
else
update app.settlement_lines
set status = 'amount_mismatch',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, txn_id, type, detail)
values (p_settlement, ln.id, cand, 'amount_mismatch',
format('local % %s vs provider % %s for ref %',
cand_amount, ln.currency, ln.amount, ln.currency, ln.external_ref));
e_count := e_count + 1;
end if;
end if;
end loop;
-- Now check for `extra_local`: completed local transactions in the
-- period whose external_ref is not present on the provider statement.
if svc_codes is not null then
insert into app.reconciliation_exceptions(settlement_id, txn_id, type, detail)
select p_settlement, t.id, 'extra_local',
format('local txn % has external_ref % but provider did not list it',
t.id, t.external_ref)
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.service_code = any(svc_codes)
and t.external_ref is not null
and (t.occurred_at at time zone 'UTC')::date between s.period_start and s.period_end
and not exists (
select 1 from app.settlement_lines sl
where sl.settlement_id = p_settlement
and sl.external_ref = t.external_ref
)
and not exists (
select 1 from app.reconciliation_exceptions r
where r.settlement_id = p_settlement
and r.txn_id = t.id
and r.type = 'extra_local'
);
get diagnostics e_count = row_count; -- approximate increment
end if;
-- Final status
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
update app.settlements set status = 'has_exceptions' where id = p_settlement;
else
update app.settlements set status = 'matched' where id = p_settlement;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_matched', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement,
'matched', m_count, 'exceptions', e_count));
matched := m_count; exceptions := e_count; return next;
end;
$$;
revoke all on function app.run_match(uuid) from public;
grant execute on function app.run_match(uuid) to authenticated;
-- Resolve a single exception. Owner-only with mandatory note.
create or replace function app.resolve_exception(
p_exception uuid,
p_note text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
e app.reconciliation_exceptions%rowtype;
s app.settlements%rowtype;
begin
if p_note is null or length(btrim(p_note)) < 5 then
raise exception 'resolution note >= 5 chars required';
end if;
select * into e from app.reconciliation_exceptions where id = p_exception;
if e.id is null then raise exception 'exception not found'; end if;
select * into s from app.settlements where id = e.settlement_id;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if e.resolved_at is not null then
raise exception 'exception already resolved';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.reconciliation_exceptions
set resolved_at = now(),
resolved_by = auth.uid(),
resolution_note = p_note
where id = p_exception;
-- If no open exceptions remain on this settlement, flip back to matched.
if not exists (
select 1 from app.reconciliation_exceptions
where settlement_id = s.id and resolved_at is null
) then
update app.settlements set status = 'matched' where id = s.id;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_exception_resolved', s.shop_id, null,
jsonb_build_object('exception_id', p_exception));
end;
$$;
revoke all on function app.resolve_exception(uuid, text) from public;
grant execute on function app.resolve_exception(uuid, text) to authenticated;
-- Close the settlement once all exceptions are resolved.
create or replace function app.close_settlement(p_settlement uuid)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if s.status not in ('matched') then
raise exception 'settlement must be in MATCHED state to close (was %)', s.status;
end if;
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
raise exception 'cannot close: open exceptions remain';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.settlements
set status = 'closed', closed_at = now(), closed_by = auth.uid()
where id = p_settlement;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_closed', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement));
end;
$$;
revoke all on function app.close_settlement(uuid) from public;
grant execute on function app.close_settlement(uuid) to authenticated;
-- =====================================================================
-- Reporting views
-- =====================================================================
create or replace view app.v_unmatched_external as
select s.shop_id, s.provider, s.period_start, s.period_end,
sl.external_ref, sl.amount, sl.currency, sl.status
from app.settlement_lines sl
join app.settlements s on s.id = sl.settlement_id
where sl.status <> 'matched';
create or replace view app.v_open_exceptions as
select s.shop_id, s.provider, s.period_start, s.period_end,
e.id as exception_id, e.type, e.detail, e.created_at
from app.reconciliation_exceptions e
join app.settlements s on s.id = e.settlement_id
where e.resolved_at is null;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.settlements enable row level security;
alter table app.settlement_lines enable row level security;
alter table app.reconciliation_exceptions enable row level security;
alter table app.settlements force row level security;
alter table app.settlement_lines force row level security;
alter table app.reconciliation_exceptions force row level security;
revoke insert, update, delete on app.settlements from authenticated;
revoke insert, update, delete on app.settlement_lines from authenticated;
revoke insert, update, delete on app.reconciliation_exceptions from authenticated;
drop policy if exists settle_select on app.settlements;
create policy settle_select on app.settlements
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.settlements to authenticated;
drop policy if exists settle_lines_select on app.settlement_lines;
create policy settle_lines_select on app.settlement_lines
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = settlement_lines.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.settlement_lines to authenticated;
drop policy if exists recon_exc_select on app.reconciliation_exceptions;
create policy recon_exc_select on app.reconciliation_exceptions
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = reconciliation_exceptions.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.reconciliation_exceptions to authenticated;
-- A small helper to create a settlement (owner/manager only).
create or replace function app.create_settlement(
p_shop uuid,
p_provider app.settlement_provider,
p_period_start date,
p_period_end date,
p_file_url text,
p_file_sha256 text,
p_total_amount numeric,
p_total_currency app.currency_code
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare sid uuid;
begin
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if p_period_end < p_period_start then
raise exception 'period_end before period_start';
end if;
insert into app.settlements(shop_id, provider, period_start, period_end,
file_url, file_sha256, total_amount, total_currency)
values (p_shop, p_provider, p_period_start, p_period_end,
p_file_url, p_file_sha256, p_total_amount, p_total_currency)
returning id into sid;
perform app.log_auth_event('settlement_imported', p_shop, null,
jsonb_build_object('settlement_id', sid, 'provider', p_provider));
return sid;
end;
$$;
revoke all on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) from public;
grant execute on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) to authenticated;
-- End migration 0009 ----------------------------------------------------
@@ -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 ----------------------------------------------------
+222
View File
@@ -0,0 +1,222 @@
-- =====================================================================
-- Migration 0011 — Hardening & ops (roadmap Step 12).
--
-- 1. pg_cron schedules: alert detector + chain verifier.
-- 2. Daily off-site hash anchor (writes the day's last row_hash per shop
-- to app.daily_anchors; an external job copies these to S3/Glacier).
-- 3. HMAC + PIN secret rotation procedures with audit.
-- 4. DDL lockdown advisory (event trigger blocking DDL by anyone other
-- than the migration role).
-- 5. NTP / clock-skew guard at INSERT time on app.transactions.
--
-- Threat-model rows: 4, 7, 17, 22, 23, 25.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. pg_cron schedules. Supabase ships pg_cron in the `extensions`
-- schema. Each task runs as the table owner thanks to SECURITY DEFINER.
-- ---------------------------------------------------------------------
create extension if not exists pg_cron;
-- Run alert detectors every 5 minutes.
do $$ begin
perform cron.schedule('app_alert_detectors_5m',
'*/5 * * * *',
$cmd$ select app.run_alert_detectors(); $cmd$);
exception when others then null; -- already scheduled
end $$;
-- Verify the hash chain hourly per shop. We don't bail loudly here;
-- run_alert_detectors() raises a chain_break alert if verify_chain fails.
do $$ begin
perform cron.schedule('app_chain_verify_hourly',
'7 * * * *',
$cmd$ select 1 from (
select s.id, (select bool_and(ok) from app.verify_chain(s.id))
from app.shops s
) v; $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 2. Daily off-site anchor.
-- The most important fraud control after recon: every night, copy
-- the last row_hash per shop into a row that is written ONCE,
-- timestamped, and exported to immutable storage. If anyone tampers
-- with history, today's anchor will not chain back to yesterday's.
-- ---------------------------------------------------------------------
create table if not exists app.daily_anchors (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
anchor_date date not null,
last_txn_id uuid,
last_ref_no bigint,
last_row_hash bytea,
txn_count_to_date bigint not null,
computed_at timestamptz not null default now(),
unique (shop_id, anchor_date)
);
alter table app.daily_anchors enable row level security;
alter table app.daily_anchors force row level security;
revoke insert, update, delete on app.daily_anchors from authenticated;
create or replace function app._daily_anchors_guard()
returns trigger language plpgsql as $$
begin
raise exception 'daily_anchors are append-only';
end;
$$;
drop trigger if exists trg_daily_anchors_guard on app.daily_anchors;
create trigger trg_daily_anchors_guard before update or delete on app.daily_anchors
for each row execute function app._daily_anchors_guard();
drop policy if exists daily_anchors_select on app.daily_anchors;
create policy daily_anchors_select on app.daily_anchors
for select to authenticated
using (app.has_any_role_in_shop(shop_id,
array['owner','auditor']::app.business_role[]));
grant select on app.daily_anchors to authenticated;
create or replace function app.write_daily_anchors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
for r in
with last_row as (
select distinct on (shop_id)
shop_id, id, reference_no, row_hash, occurred_at
from app.transactions
where (occurred_at at time zone 'UTC')::date
= (now() at time zone 'UTC')::date - 1
order by shop_id, reference_no desc
)
select lr.shop_id, lr.id, lr.reference_no, lr.row_hash,
(now() at time zone 'UTC')::date - 1 as anchor_date,
(select count(*) from app.transactions t
where t.shop_id = lr.shop_id
and t.reference_no <= lr.reference_no) as cnt
from last_row lr
loop
insert into app.daily_anchors(shop_id, anchor_date, last_txn_id,
last_ref_no, last_row_hash, txn_count_to_date)
values (r.shop_id, r.anchor_date, r.id,
r.reference_no, r.row_hash, r.cnt)
on conflict (shop_id, anchor_date) do nothing;
n := n + 1;
end loop;
perform app.log_auth_event('daily_anchor_written', null, null,
jsonb_build_object('rows', n));
return n;
end;
$$;
revoke all on function app.write_daily_anchors() from public;
grant execute on function app.write_daily_anchors() to authenticated;
-- 02:15 Beirut time = 23:15 UTC the previous day; at that hour the till
-- is closed and the day's last txn already exists.
do $$ begin
perform cron.schedule('app_daily_anchor',
'15 23 * * *',
$cmd$ select app.write_daily_anchors(); $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 3. Secret rotation with audit. The HMAC key was created in 0007;
-- rotating it invalidates all printed receipts but new ones become
-- forgery-resistant. PIN rotation is per-user.
-- ---------------------------------------------------------------------
create table if not exists app.secret_rotations (
id uuid primary key default gen_random_uuid(),
secret_name text not null,
rotated_by uuid not null references auth.users(id) default auth.uid(),
rotated_at timestamptz not null default now(),
reason text not null check (length(btrim(reason)) >= 5)
);
alter table app.secret_rotations enable row level security;
alter table app.secret_rotations force row level security;
revoke insert, update, delete on app.secret_rotations from authenticated;
drop policy if exists secret_rotations_select on app.secret_rotations;
create policy secret_rotations_select on app.secret_rotations
for select to authenticated
using (app.is_owner_anywhere());
grant select on app.secret_rotations to authenticated;
create or replace function app._secret_rotations_guard()
returns trigger language plpgsql as $$
begin
raise exception 'secret_rotations are append-only';
end;
$$;
drop trigger if exists trg_secret_rotations_guard on app.secret_rotations;
create trigger trg_secret_rotations_guard before update or delete on app.secret_rotations
for each row execute function app._secret_rotations_guard();
create or replace function app.log_secret_rotation(p_name text, p_reason text)
returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare rid uuid;
begin
if not app.is_owner_anywhere() then
raise exception 'owner role required';
end if;
insert into app.secret_rotations(secret_name, reason)
values (p_name, p_reason) returning id into rid;
return rid;
end;
$$;
revoke all on function app.log_secret_rotation(text, text) from public;
grant execute on function app.log_secret_rotation(text, text) to authenticated;
-- ---------------------------------------------------------------------
-- 4. DDL lockdown advisory.
-- Anyone with `authenticated` should not be able to issue DDL anyway,
-- but Supabase ships a `service_role` key. This event trigger raises
-- if DDL is attempted from anything other than the migration owner.
-- ---------------------------------------------------------------------
create or replace function app._ddl_lock()
returns event_trigger
language plpgsql
as $$
begin
-- Allow the role that owns the schema (typically `postgres` running
-- supabase migrations) and the cron worker. Block everyone else.
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'DDL is locked: caller % may not modify schema', current_user;
end if;
end;
$$;
drop event trigger if exists app_ddl_lock;
create event trigger app_ddl_lock
on ddl_command_start
execute function app._ddl_lock();
-- ---------------------------------------------------------------------
-- 5. Clock-skew guard. A till with a manipulated clock can backdate or
-- pre-date transactions to hide them from a shift. Reject inserts
-- whose `occurred_at` is more than 5 minutes off server `now()`.
-- ---------------------------------------------------------------------
create or replace function app._txn_clock_guard()
returns trigger language plpgsql as $$
begin
if abs(extract(epoch from (new.occurred_at - now()))) > 300 then
raise exception
'clock skew rejected: occurred_at=% server now()=%', new.occurred_at, now();
end if;
return new;
end;
$$;
drop trigger if exists trg_txn_clock_guard on app.transactions;
-- Fires before the existing txn_before_insert (alphabetical 'a' < 't').
create trigger trg_a_txn_clock_guard before insert on app.transactions
for each row execute function app._txn_clock_guard();
-- End migration 0011 ----------------------------------------------------
@@ -0,0 +1,667 @@
-- =====================================================================
-- Migration 0013 — Step 13a corrections + Step 13b record-RPCs.
--
-- 1. Replaces views in 0010 / 0006 / 0012 that referenced columns that
-- don't exist in the actual schema (cashier_id, opening_float_*,
-- services.requires_*, AML view shop_id, etc.).
-- 2. Defines the public API the React UI actually needs:
-- - app.me() -> current user profile + roles
-- - app.v_my_shops -> shops the caller belongs to
-- - app.my_open_shift(shop) -> the caller's open shift
-- - app.v_services_active -> service catalog
-- - app.v_my_recent_transactions
-- 3. Defines `record_*` SECURITY DEFINER helpers, one per service
-- family, that insert the parent transaction row AND the matching
-- detail row in a single round-trip. The deferred constraint trigger
-- from 0004 fires at COMMIT and would otherwise reject any client
-- pattern that tried to do those two writes in separate HTTP calls.
-- 4. Seeds app.services with the 12 codes the UI uses.
-- 5. Revokes INSERT on app.transactions from authenticated; the only
-- legal path is one of the record_* functions defined here.
--
-- Threat-model rows: 1, 5, 6, 8, 19, 21.
-- =====================================================================
-- =====================================================================
-- Service catalog seed (idempotent)
-- =====================================================================
insert into app.services(code, name, category) values
('OMT_SEND', 'OMT — Send', 'money_transfer'),
('OMT_RECEIVE', 'OMT — Receive', 'money_transfer'),
('OMT_BILL', 'Bill payment via OMT', 'bills'),
('WU_SEND', 'Western Union — Send', 'money_transfer'),
('WU_RECEIVE', 'Western Union — Recv', 'money_transfer'),
('WHISH_SEND', 'Whish — Send', 'money_transfer'),
('ALFA_RECHARGE', 'Alfa recharge', 'telecom_recharge'),
('TOUCH_RECHARGE', 'touch recharge', 'telecom_recharge'),
('OGERO_RECHARGE', 'Ogero recharge', 'telecom_recharge'),
('INTERNET_RECHARGE', 'Internet voucher', 'telecom_recharge'),
('EDL_BILL', 'EDL electricity bill', 'bills'),
('GOODS_SALE', 'Goods sale', 'goods'),
('REPAIR', 'Phone / device repair', 'repair'),
('REFUND', 'Refund', 'refund')
on conflict (code) do update set name = excluded.name, category = excluded.category;
-- =====================================================================
-- Read views the UI consumes
-- =====================================================================
drop view if exists app.v_services_active;
create view app.v_services_active as
select code, name, category, is_active
from app.services
where is_active = true
order by category, name;
grant select on app.v_services_active to authenticated;
drop view if exists app.v_my_shops;
create view app.v_my_shops as
select s.id as shop_id, s.name, a.role
from app.shops s
join app.user_shop_assignments a
on a.shop_id = s.id and a.user_id = auth.uid();
grant select on app.v_my_shops to authenticated;
drop view if exists app.v_my_tills;
create view app.v_my_tills as
select t.id as till_id, t.shop_id, t.name, t.is_active
from app.tills t
where t.is_active
and exists (
select 1 from app.user_shop_assignments a
where a.shop_id = t.shop_id and a.user_id = auth.uid()
);
grant select on app.v_my_tills to authenticated;
drop view if exists app.v_my_recent_transactions;
create view app.v_my_recent_transactions as
select t.id, t.reference_no, t.shop_id, t.till_id, t.shift_id,
t.service_code, s.name as service_name, s.category,
t.payment_method,
t.gross_usd, t.gross_lbp,
t.fee_usd + t.commission_usd as revenue_usd,
t.fee_lbp + t.commission_lbp as revenue_lbp,
t.external_ref, t.external_ref_provider,
t.beneficiary_name, t.beneficiary_phone,
t.status, t.occurred_at, t.user_id
from app.transactions t
join app.services s on s.code = t.service_code
where t.user_id = auth.uid()
or app.has_any_role_in_shop(t.shop_id,
array['owner','manager','auditor']::app.business_role[]);
grant select on app.v_my_recent_transactions to authenticated;
-- "Who am I" — single round-trip for the auth bootstrap.
create or replace function app.me()
returns table (
user_id uuid,
full_name text,
is_active boolean,
is_owner_anywhere boolean,
shops jsonb
) language sql
security definer
set search_path = app, public
stable
as $$
select
auth.uid() as user_id,
coalesce(p.full_name, '') as full_name,
coalesce(p.is_active, true) as is_active,
app.is_owner_anywhere() as is_owner_anywhere,
coalesce((
select jsonb_agg(jsonb_build_object(
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
from app.user_shop_assignments a
join app.shops s on s.id = a.shop_id
where a.user_id = auth.uid()
), '[]'::jsonb) as shops
from app.user_profiles p
where p.user_id = auth.uid()
union all
-- profile may not exist yet; return one row anyway
select auth.uid(), '', true,
app.is_owner_anywhere(),
coalesce((
select jsonb_agg(jsonb_build_object(
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
from app.user_shop_assignments a
join app.shops s on s.id = a.shop_id
where a.user_id = auth.uid()
), '[]'::jsonb)
where not exists (select 1 from app.user_profiles where user_id = auth.uid())
limit 1;
$$;
revoke all on function app.me() from public;
grant execute on function app.me() to authenticated;
-- The caller's open shift in a given shop. Used by the till UI to
-- decide whether the "New transaction" button is enabled.
create or replace function app.my_open_shift(p_shop uuid)
returns table (
shift_id uuid,
till_id uuid,
opened_at timestamptz,
status app.shift_status,
opening_usd numeric,
opening_lbp numeric
) language sql
security definer
set search_path = app, public
stable
as $$
select id, till_id, opened_at, status, opening_usd, opening_lbp
from app.shifts
where shop_id = p_shop
and user_id = auth.uid()
and status = 'open'
order by opened_at desc
limit 1;
$$;
revoke all on function app.my_open_shift(uuid) from public;
grant execute on function app.my_open_shift(uuid) to authenticated;
-- =====================================================================
-- Internal: insert a parent transaction row.
-- All record_* wrappers below call this and then insert the detail row
-- in the same DB transaction so the deferred constraint trigger from
-- 0004 (txn must have a detail at COMMIT) is satisfied.
-- =====================================================================
create or replace function app._insert_txn(
p_shop uuid,
p_till uuid,
p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric,
p_gross_lbp numeric,
p_fee_usd numeric,
p_fee_lbp numeric,
p_commission_usd numeric,
p_commission_lbp numeric,
p_fx_rate_used numeric,
p_external_ref_provider text,
p_external_ref text,
p_beneficiary_name text,
p_beneficiary_phone text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid; v_tid uuid;
begin
-- Role check
if not app.has_any_role_in_shop(p_shop,
array['owner','manager','cashier']::app.business_role[]) then
raise exception 'no role in shop %', p_shop;
end if;
-- Service must exist & be active
if not exists (select 1 from app.services
where code = p_service_code and is_active) then
raise exception 'unknown or inactive service %', p_service_code;
end if;
-- Amounts
if coalesce(p_gross_usd,0) < 0 or coalesce(p_gross_lbp,0) < 0
or coalesce(p_fee_usd,0) < 0 or coalesce(p_fee_lbp,0) < 0
or coalesce(p_commission_usd,0) < 0 or coalesce(p_commission_lbp,0) < 0 then
raise exception 'amounts must be non-negative';
end if;
-- Open shift
select id into v_shift
from app.shifts
where shop_id = p_shop and till_id = p_till
and user_id = auth.uid() and status = 'open'
order by opened_at desc limit 1;
if v_shift is null then
raise exception 'no open shift for caller in shop %, till %', p_shop, p_till;
end if;
insert into app.transactions(
shop_id, till_id, shift_id, user_id, service_code, status,
payment_method,
gross_usd, gross_lbp, fee_usd, fee_lbp,
commission_usd, commission_lbp, fx_rate_used,
external_ref_provider, external_ref,
beneficiary_name, beneficiary_phone,
customer_id, notes,
created_by,
-- placeholder; the BEFORE INSERT hash trigger from 0003 fills these
row_hash
) values (
p_shop, p_till, v_shift, auth.uid(), p_service_code, 'completed',
p_payment_method,
coalesce(p_gross_usd,0), coalesce(p_gross_lbp,0),
coalesce(p_fee_usd,0), coalesce(p_fee_lbp,0),
coalesce(p_commission_usd,0), coalesce(p_commission_lbp,0),
p_fx_rate_used,
p_external_ref_provider, p_external_ref,
p_beneficiary_name, p_beneficiary_phone,
p_customer_id, p_notes,
auth.uid(),
decode('00','hex') -- the BEFORE INSERT trigger overwrites this
) returning id into v_tid;
return v_tid;
end;
$$;
-- Internal helper; not granted to clients.
revoke all on function app._insert_txn(
uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric,
numeric, text, text, text, text, uuid, text) from public;
-- Lock down direct INSERT — only the record_* wrappers may write.
revoke insert on app.transactions from authenticated;
revoke insert on app.omt_send_details, app.omt_receive_details,
app.bill_payment_details, app.recharge_details,
app.goods_sale_details, app.repair_details
from authenticated;
-- =====================================================================
-- record_recharge: Alfa / touch / Ogero / Internet voucher OR e-recharge
-- =====================================================================
create or replace function app.record_recharge(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_operator text, p_msisdn text, p_product_code text,
p_voucher_serial text, p_e_recharge_ref text,
p_unit_face_usd numeric, p_unit_cost_usd numeric,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_operator, p_voucher_serial, null, p_msisdn, null, p_notes);
insert into app.recharge_details(
txn_id, operator, msisdn, product_code,
voucher_serial, e_recharge_provider_ref,
unit_face_value_usd, unit_cost_usd
) values (
v_txn, p_operator, p_msisdn, p_product_code,
nullif(btrim(p_voucher_serial),''),
nullif(btrim(p_e_recharge_ref),''),
p_unit_face_usd, p_unit_cost_usd
);
return v_txn;
end; $$;
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) from public;
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) to authenticated;
-- =====================================================================
-- record_omt_send
-- =====================================================================
create or replace function app.record_omt_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_sender_dob date, p_sender_nationality text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_destination_country text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'OMT_SEND', p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
'OMT', p_external_ref,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_send_details(
txn_id, direction,
sender_full_name, sender_id_type, sender_id_number, sender_phone,
sender_dob, sender_nationality,
beneficiary_full_name, beneficiary_phone, destination_country,
purpose_code, purpose_note, kyc_doc_url
) values (
v_txn, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
p_sender_dob, p_sender_nationality,
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
p_purpose_code, p_purpose_note, p_kyc_doc_url
);
return v_txn;
end; $$;
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text) from public;
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_omt_receive (payout)
-- =====================================================================
create or replace function app.record_omt_receive(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_payout_code text,
p_beneficiary_full_name text,
p_beneficiary_id_type app.id_doc_type,
p_beneficiary_id_number text,
p_beneficiary_phone text,
p_origin_country text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'OMT_RECEIVE', p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
'OMT', p_payout_code,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_receive_details(
txn_id, payout_code,
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
beneficiary_phone, origin_country, kyc_doc_url
) values (
v_txn, p_payout_code,
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
);
return v_txn;
end; $$;
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text) from public;
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_bill (OMT_BILL / EDL_BILL)
-- =====================================================================
create or replace function app.record_bill(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_biller_code text, p_account_number text,
p_period text, p_customer_name text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
if p_service_code not in ('OMT_BILL','EDL_BILL') then
raise exception 'record_bill only for bill services, got %', p_service_code;
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_biller_code, p_external_ref,
p_customer_name, null,
p_customer_id, p_notes);
insert into app.bill_payment_details(
txn_id, biller_code, account_number, period, customer_name
) values (
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
);
return v_txn;
end; $$;
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) from public;
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_goods_sale (single line)
-- =====================================================================
create or replace function app.record_goods_sale(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_sku text, p_qty integer,
p_unit_cost_usd numeric, p_unit_price_usd numeric,
p_serial_number text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
if p_qty is null or p_qty <= 0 then
raise exception 'qty must be > 0';
end if;
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.goods_sale_details(
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
) values (
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
);
return v_txn;
end; $$;
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
-- =====================================================================
-- record_repair
-- =====================================================================
create or replace function app.record_repair(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_device_type text, p_device_imei text,
p_issue_summary text, p_warranty_days integer,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.repair_details(
txn_id, device_type, device_imei, issue_summary, warranty_days
) values (
v_txn, p_device_type, p_device_imei, p_issue_summary,
coalesce(p_warranty_days, 0)
);
return v_txn;
end; $$;
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
-- =====================================================================
-- Re-define the broken views from 0010 against the actual schema.
-- =====================================================================
drop view if exists app.v_z_report cascade;
create 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,
cm.net_lbp as expected_close_lbp,
sh.declared_close_usd, sh.declared_close_lbp,
coalesce(sh.declared_close_usd, 0) - cm.net_usd as variance_usd,
coalesce(sh.declared_close_lbp, 0) - 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;
grant select on app.v_z_report to authenticated;
drop view if exists app.v_employee_scorecard_30d cascade;
create 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,
(coalesce(sh.declared_close_usd,0) - z.expected_close_usd) as var_usd,
(coalesce(sh.declared_close_lbp,0) - 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
),
ovr 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 ovr 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;
grant select on app.v_employee_scorecard_30d to authenticated;
-- AML views: re-create with shop_id and aliases the detector loop expects.
drop view if exists app.v_aml_structuring_by_customer cascade;
create view app.v_aml_structuring_by_customer as
with d as (
select t.shop_id, t.customer_id, t.service_code,
(t.occurred_at at time zone 'UTC')::date as day,
count(*) as cnt,
sum(t.gross_usd) as sum_usd,
sum(t.gross_lbp) as sum_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
and t.customer_id is not null
group by 1,2,3,4
)
select d.*
from d
where d.cnt >= 3
and (
d.sum_usd >= 0.8 * coalesce(
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD'), 1e18)
or d.sum_lbp >= 0.8 * coalesce(
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP'), 1e18));
grant select on app.v_aml_structuring_by_customer to authenticated;
drop view if exists app.v_aml_same_beneficiary_burst cascade;
create view app.v_aml_same_beneficiary_burst as
select t.shop_id,
t.beneficiary_phone,
date_trunc('hour', t.occurred_at) as window_hour,
count(*) as cnt,
count(distinct t.user_id) as cashier_count,
sum(t.gross_usd) as sum_usd,
sum(t.gross_lbp) as sum_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','WU_SEND')
and t.beneficiary_phone is not null
group by 1,2,3
having count(*) >= 3 and count(distinct t.user_id) >= 2;
grant select on app.v_aml_same_beneficiary_burst to authenticated;
-- End migration 0013 ----------------------------------------------------
@@ -0,0 +1,105 @@
-- =====================================================================
-- Migration 0014 — Product Catalog for fixed-price commodities
--
-- Replaces manual cost/face value entry for standard recharges, goods,
-- and fixed-fee services with a controlled catalog managed by owners.
-- =====================================================================
create table if not exists app.products (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null, -- e.g., 'ALFA_RECHARGE', 'TOUCH_RECHARGE'
product_code text not null, -- e.g., 'ALFA_10', 'TOUCH_22.73'
name citext not null, -- User-facing display name
unit_cost_usd numeric(12,2) not null default 0 constraint ck_cost_usd_nonneg check (unit_cost_usd >= 0),
unit_face_usd numeric(12,2) not null default 0 constraint ck_face_usd_nonneg check (unit_face_usd >= 0),
unit_cost_lbp numeric(16,0) not null default 0 constraint ck_cost_lbp_nonneg check (unit_cost_lbp >= 0),
unit_face_lbp numeric(16,0) not null default 0 constraint ck_face_lbp_nonneg check (unit_face_lbp >= 0),
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint unq_product_code_per_shop unique (shop_id, service_code, product_code)
);
create index if not exists idx_products_shop_service on app.products(shop_id, service_code) where is_active = true;
-- Update trigger
create or replace function app.trg_products_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
do $$ begin
create trigger trg_products_updated_at
before update on app.products
for each row execute function app.trg_products_updated_at();
exception when duplicate_object then null; end $$;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.products enable row level security;
alter table app.products force row level security;
-- Cashiers and managers can select active products
create policy "Staff can read active products"
on app.products for select
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager', 'cashier', 'auditor']::app.business_role[])
and (is_active = true or app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[]))
);
-- Owners and admins can manage products globally or per shop
create policy "Owners and managers can insert products"
on app.products for insert
to authenticated
with check (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
create policy "Owners and managers can update products"
on app.products for update
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
)
with check (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
create policy "Owners and managers can delete products"
on app.products for delete
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
-- =====================================================================
-- Seed with initial safety defaults
-- =====================================================================
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'ALFA_RECHARGE', 'ALFA_10', 'Alfa $10', 10.00, 9.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'ALFA_RECHARGE', 'ALFA_22.73', 'Alfa $22.73', 22.73, 22.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'TOUCH_RECHARGE', 'TOUCH_10', 'Touch $10', 10.00, 9.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'TOUCH_RECHARGE', 'TOUCH_22.73', 'Touch $22.73', 22.73, 22.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
+70
View File
@@ -0,0 +1,70 @@
-- =====================================================================
-- Migration 0015 — Mid-Day Shift Cash Drops
--
-- Enables cashiers to "drop" large sums of accumulated cash (esp USD payout cash)
-- into a safe midway through a shift, removing their liability without
-- requiring them to close out and open a brand new shift.
-- =====================================================================
create or replace function app.record_cash_drop(
p_shift_id uuid,
p_drop_usd numeric,
p_drop_lbp numeric,
p_notes text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_till uuid;
v_status text;
v_user uuid;
begin
select shop_id, till_id, status, user_id
into v_shop, v_till, v_status, v_user
from app.shifts
where id = p_shift_id;
if v_shop is null then
raise exception 'Shift not found';
end if;
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
raise exception 'Only the shift owner or a manager may record a drop';
end if;
if v_status <> 'open' then
raise exception 'Must have an open shift to record a soft drop';
end if;
if p_drop_usd < 0 or p_drop_lbp < 0 then
raise exception 'Drop amounts cannot be negative';
end if;
if p_drop_usd = 0 and p_drop_lbp = 0 then
raise exception 'Must drop > 0 in at least one currency';
end if;
-- Create a matching cash_movements record reducing the drawer balance
insert into app.cash_movements(
shift_id, movement_type, currency, amount, external_ref
)
select
p_shift_id,
'safe_drop',
case when d.idx = 1 then 'USD' else 'LBP' end,
case when d.idx = 1 then p_drop_usd else p_drop_lbp end,
p_notes
from (values (1), (2)) as d(idx)
where (d.idx = 1 and p_drop_usd > 0)
or (d.idx = 2 and p_drop_lbp > 0);
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
jsonb_build_object('shift_id', p_shift_id, 'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
@@ -0,0 +1,78 @@
-- =====================================================================
-- Migration 0016: Shift Assignments
-- Extends open_shift so managers/owners can assign a shift to any employee.
-- =====================================================================
drop function if exists app.open_shift(uuid, numeric, numeric);
create or replace function app.open_shift(
p_till_id uuid,
p_opening_usd numeric,
p_opening_lbp numeric,
p_assigned_user_id uuid default null
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_shift uuid;
v_target_user uuid;
begin
if p_opening_usd is null or p_opening_lbp is null then
raise exception 'opening counts are required';
end if;
if p_opening_usd < 0 or p_opening_lbp < 0 then
raise exception 'opening counts must be non-negative';
end if;
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
if v_shop is null then
raise exception 'till % not found or inactive', p_till_id;
end if;
v_target_user := coalesce(p_assigned_user_id, auth.uid());
-- Caller must have a role to open.
if not app.has_any_role_in_shop(v_shop, array['owner','manager','cashier']::app.business_role[]) then
raise exception 'not authorized to open a shift on this till';
end if;
-- If trying to open for someone else, must be owner or manager
if v_target_user <> auth.uid() then
if not app.has_any_role_in_shop(v_shop, array['owner','manager']::app.business_role[]) then
raise exception 'only managers or owners can assign shifts to other users';
end if;
end if;
-- Target user must have a role in the shop
if not exists (
select 1 from app.user_shop_assignments
where user_id = v_target_user and shop_id = v_shop
) then
raise exception 'target user does not have a role in this shop';
end if;
-- Reject if any non-closed shift exists on this till.
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
raise exception 'till % already has an active shift; close it first', p_till_id;
end if;
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
values (p_till_id, v_shop, v_target_user, auth.uid(), p_opening_usd, p_opening_lbp)
returning id into v_shift;
-- Record the opening float as a cash movement for clean ledgers.
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
perform app.log_auth_event('shift_opened', v_shop, null,
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id, 'assigned_user_id', v_target_user));
return v_shift;
end;
$$;
revoke all on function app.open_shift(uuid, numeric, numeric, uuid) from public;
grant execute on function app.open_shift(uuid, numeric, numeric, uuid) to authenticated;
@@ -0,0 +1,20 @@
create or replace function app.get_shop_users(p_shop_id uuid)
returns table (
user_id uuid,
full_name text,
role text
) language sql
security definer
set search_path = app, public
as $$
select
usa.user_id,
up.full_name,
usa.role::text
from app.user_shop_assignments usa
join app.user_profiles up on up.user_id = usa.user_id
where usa.shop_id = p_shop_id;
$$;
revoke all on function app.get_shop_users(uuid) from public;
grant execute on function app.get_shop_users(uuid) to authenticated;
@@ -0,0 +1,506 @@
-- =====================================================================
-- Migration 0018 — Money-transfer cash + float coupling.
--
-- Closes the largest hole in the cash-control model: until now,
-- record_omt_send / record_omt_receive / record_bill only inserted into
-- app.transactions and the detail table. They did NOT post anything to
-- app.cash_movements or app.float_movements, so:
--
-- * v_z_report.expected_close_usd = sum(cash_movements) was wrong by
-- the entire transfer turnover, hiding cashier shortages.
-- * The OMT/Whish/WU/biller float balance never moved, so we couldn't
-- tell who owed whom and the matcher could only compare by
-- external_ref, never by money.
-- * Threat-model rows #1, #5, #21 had no DB-level enforcement for
-- money transfers (only recharges had the deferred-trigger
-- constraint).
--
-- This migration:
-- 1. Adds app._post_cash_for_txn / app._post_float_for_txn helpers.
-- 2. Adds app._get_or_create_float(shop, provider, currency).
-- 3. Re-defines record_omt_send / record_omt_receive / record_bill so
-- each posts the cash leg (when payment_method is cash_*) and the
-- provider-float leg in the same SECURITY DEFINER body.
-- 4. Adds record_whish_send, record_wu_send, record_wu_receive so the
-- UI does not silently file Whish/WU under provider='OMT'.
-- 5. Adds a deferred constraint trigger that requires every completed
-- money-transfer / bill txn to have at least one float_movement
-- row. Recharges already have their own coupling trigger from
-- 0005; goods sales have one too.
--
-- Threat-model rows: 1, 5, 7, 8, 14, 21, 24.
-- =====================================================================
-- =====================================================================
-- Helper: get_or_create the float account the txn should debit/credit.
-- =====================================================================
create or replace function app._get_or_create_float(
p_shop uuid,
p_provider app.float_provider,
p_currency app.currency_code
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_id uuid;
begin
select id into v_id from app.floats
where shop_id = p_shop and provider = p_provider and currency = p_currency;
if v_id is null then
insert into app.floats(shop_id, provider, currency)
values (p_shop, p_provider, p_currency)
returning id into v_id;
-- Initialise the cached balance row at zero.
insert into app.float_balances(float_id, balance) values (v_id, 0)
on conflict (float_id) do nothing;
end if;
return v_id;
end;
$$;
revoke all on function app._get_or_create_float(uuid, app.float_provider, app.currency_code)
from public;
-- =====================================================================
-- Helper: post the cash leg for a customer-facing transaction.
--
-- Convention (matches 0002 cash_movements):
-- * Positive amount = cash into till.
-- * Negative amount = cash out of till.
-- This function takes a "customer movement" sign:
-- * p_customer_paid > 0 -> cash_in (sale_in) amount = +p_customer_paid
-- * p_customer_paid < 0 -> cash_out (payout_out) amount = p_customer_paid
-- For non-cash payment methods (whish, omt_wallet, card, bank_transfer)
-- the cash leg is skipped — those balances live on their own floats.
-- =====================================================================
create or replace function app._post_cash_for_txn(
p_txn_id uuid,
p_payment_method app.payment_method,
p_customer_usd numeric,
p_customer_lbp numeric
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid;
v_type app.cash_movement_type;
begin
-- Only cash-in-till payment methods produce a cash leg.
if p_payment_method not in ('cash_usd','cash_lbp') then
return;
end if;
select shift_id into v_shift from app.transactions where id = p_txn_id;
if v_shift is null then
raise exception 'txn % not found while posting cash leg', p_txn_id;
end if;
-- For cash_usd payment method, only USD leg may move; same for LBP.
if p_payment_method = 'cash_usd' then
if coalesce(p_customer_lbp, 0) <> 0 then
raise exception 'cash_usd payment must not move LBP (got %)', p_customer_lbp;
end if;
if coalesce(p_customer_usd, 0) = 0 then return; end if;
v_type := case when p_customer_usd > 0 then 'sale_in'::app.cash_movement_type
else 'payout_out'::app.cash_movement_type end;
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (v_shift, v_type, 'USD', p_customer_usd, p_txn_id, 'auto: txn cash leg');
else -- cash_lbp
if coalesce(p_customer_usd, 0) <> 0 then
raise exception 'cash_lbp payment must not move USD (got %)', p_customer_usd;
end if;
if coalesce(p_customer_lbp, 0) = 0 then return; end if;
v_type := case when p_customer_lbp > 0 then 'sale_in'::app.cash_movement_type
else 'payout_out'::app.cash_movement_type end;
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (v_shift, v_type, 'LBP', p_customer_lbp, p_txn_id, 'auto: txn cash leg');
end if;
end;
$$;
revoke all on function app._post_cash_for_txn(uuid, app.payment_method, numeric, numeric)
from public;
-- =====================================================================
-- Helper: post the provider-float leg for a customer-facing transaction.
--
-- p_amount sign convention on app.float_movements:
-- + : float increases (provider owes shop more, e-recharge wallet
-- topped up, OMT credits us at settlement, ...)
-- - : float decreases (we used it up, we owe provider more cash, ...)
-- =====================================================================
create or replace function app._post_float_for_txn(
p_txn_id uuid,
p_provider app.float_provider,
p_currency app.currency_code,
p_amount numeric,
p_reason text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid; v_shift uuid; v_float uuid;
begin
if coalesce(p_amount, 0) = 0 then return; end if;
select shop_id, shift_id into v_shop, v_shift
from app.transactions where id = p_txn_id;
if v_shop is null then
raise exception 'txn % not found while posting float leg', p_txn_id;
end if;
v_float := app._get_or_create_float(v_shop, p_provider, p_currency);
insert into app.float_movements(float_id, shift_id, amount, ref_txn_id, reason)
values (v_float, v_shift, p_amount, p_txn_id, p_reason);
end;
$$;
revoke all on function app._post_float_for_txn(uuid, app.float_provider,
app.currency_code, numeric, text) from public;
-- =====================================================================
-- Map a money-transfer service code to its float provider.
-- =====================================================================
create or replace function app._money_transfer_provider(p_service text)
returns app.float_provider
language sql
immutable
as $$
select case p_service
when 'OMT_SEND' then 'OMT_CASH'::app.float_provider
when 'OMT_RECEIVE' then 'OMT_CASH'::app.float_provider
when 'OMT_BILL' then 'OMT_CASH'::app.float_provider
when 'WU_SEND' then 'OMT_CASH'::app.float_provider -- WU runs on the OMT cash pool in LB
when 'WU_RECEIVE' then 'OMT_CASH'::app.float_provider
when 'WHISH_SEND' then 'WHISH'::app.float_provider
when 'EDL_BILL' then 'OMT_CASH'::app.float_provider -- EDL paid via OMT counter
end;
$$;
-- =====================================================================
-- Re-define record_omt_send to post cash + float in one go.
-- Provider is stamped from the service code, not hard-coded.
-- =====================================================================
create or replace function app.record_omt_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_sender_dob date, p_sender_nationality text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_destination_country text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text,
p_service_code text default 'OMT_SEND' -- 'OMT_SEND' | 'WU_SEND' | 'WHISH_SEND'
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_provider_lbl text;
v_float_prov app.float_provider;
begin
if p_service_code not in ('OMT_SEND','WU_SEND','WHISH_SEND') then
raise exception 'record_omt_send: unsupported service %', p_service_code;
end if;
v_provider_lbl := case p_service_code
when 'OMT_SEND' then 'OMT'
when 'WU_SEND' then 'WU'
when 'WHISH_SEND' then 'WHISH'
end;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
v_provider_lbl, p_external_ref,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_send_details(
txn_id, direction,
sender_full_name, sender_id_type, sender_id_number, sender_phone,
sender_dob, sender_nationality,
beneficiary_full_name, beneficiary_phone, destination_country,
purpose_code, purpose_note, kyc_doc_url
) values (
v_txn, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
p_sender_dob, p_sender_nationality,
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
p_purpose_code, p_purpose_note, p_kyc_doc_url
);
-- Cash leg: customer hands over gross + fee in cash.
perform app._post_cash_for_txn(v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
-- Float leg: shop now owes the provider gross (we keep fee+comm).
v_float_prov := app._money_transfer_provider(p_service_code);
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
-coalesce(p_gross_usd,0), 'send: shop owes provider gross');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
-coalesce(p_gross_lbp,0), 'send: shop owes provider gross');
return v_txn;
end; $$;
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text, text) from public;
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text, text) to authenticated;
-- =====================================================================
-- Re-define record_omt_receive (also serves WU_RECEIVE).
-- Customer presents code, cashier hands them gross.
-- =====================================================================
create or replace function app.record_omt_receive(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_payout_code text,
p_beneficiary_full_name text,
p_beneficiary_id_type app.id_doc_type,
p_beneficiary_id_number text,
p_beneficiary_phone text,
p_origin_country text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text,
p_service_code text default 'OMT_RECEIVE' -- or 'WU_RECEIVE'
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_provider_lbl text;
v_float_prov app.float_provider;
v_net_usd numeric;
v_net_lbp numeric;
begin
if p_service_code not in ('OMT_RECEIVE','WU_RECEIVE') then
raise exception 'record_omt_receive: unsupported service %', p_service_code;
end if;
v_provider_lbl := case p_service_code
when 'OMT_RECEIVE' then 'OMT'
when 'WU_RECEIVE' then 'WU'
end;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
v_provider_lbl, p_payout_code,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_receive_details(
txn_id, payout_code,
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
beneficiary_phone, origin_country, kyc_doc_url
) values (
v_txn, p_payout_code,
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
);
-- Cash leg: shop pays gross out, may collect a small fee from beneficiary.
-- net cash to till = -gross + fee
-- (Most LB payouts have no beneficiary-side fee; if fee=0 this just
-- becomes -gross.)
v_net_usd := -coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0);
v_net_lbp := -coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0);
perform app._post_cash_for_txn(v_txn, p_payment_method, v_net_usd, v_net_lbp);
-- Float leg: provider now owes the shop gross + commission.
v_float_prov := app._money_transfer_provider(p_service_code);
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
coalesce(p_gross_usd,0) + coalesce(p_commission_usd,0),
'receive: provider owes shop gross + commission');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
coalesce(p_gross_lbp,0) + coalesce(p_commission_lbp,0),
'receive: provider owes shop gross + commission');
return v_txn;
end; $$;
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) from public;
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) to authenticated;
-- =====================================================================
-- Re-define record_bill (OMT_BILL / EDL_BILL) with cash + float legs.
-- =====================================================================
create or replace function app.record_bill(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_biller_code text, p_account_number text,
p_period text, p_customer_name text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_float_prov app.float_provider;
begin
if p_service_code not in ('OMT_BILL','EDL_BILL') then
raise exception 'record_bill only for bill services, got %', p_service_code;
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_biller_code, p_external_ref,
p_customer_name, null,
p_customer_id, p_notes);
insert into app.bill_payment_details(
txn_id, biller_code, account_number, period, customer_name
) values (
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
);
-- Cash leg: customer pays gross + fee.
perform app._post_cash_for_txn(v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
-- Float leg: shop now owes the biller's settlement counterparty
-- gross. EDL/OMT_BILL settle through the OMT cash pool in our model.
v_float_prov := app._money_transfer_provider(p_service_code);
if v_float_prov is not null then
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
-coalesce(p_gross_usd,0), 'bill: shop owes biller gross');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
-coalesce(p_gross_lbp,0), 'bill: shop owes biller gross');
end if;
return v_txn;
end; $$;
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
from public;
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
to authenticated;
-- =====================================================================
-- Convenience wrapper for Whish — same shape as omt_send_details for
-- now (sender + beneficiary). The UI sends WHISH_SEND and gets a
-- correctly tagged provider on the txn row.
-- =====================================================================
create or replace function app.record_whish_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
begin
return app.record_omt_send(
p_shop, p_till, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
p_external_ref, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
null, null,
p_beneficiary_full_name, p_beneficiary_phone, null,
p_purpose_code, p_purpose_note, p_kyc_doc_url,
p_customer_id, p_notes,
'WHISH_SEND'
);
end; $$;
revoke all on function app.record_whish_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
from public;
grant execute on function app.record_whish_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
to authenticated;
-- =====================================================================
-- Deferred constraint trigger: every completed money-transfer / bill
-- transaction must end up with at least one float_movement row. The
-- trigger fires at COMMIT, so the record_* functions above can post the
-- float leg after the txn insert in the same transaction.
-- =====================================================================
create or replace function app._money_transfer_require_movement()
returns trigger
language plpgsql
as $$
declare
ok boolean;
is_money_transfer boolean;
begin
if new.status <> 'completed' then return null; end if;
is_money_transfer := new.service_code in
('OMT_SEND','OMT_RECEIVE','OMT_BILL','EDL_BILL',
'WU_SEND','WU_RECEIVE','WHISH_SEND');
if not is_money_transfer then return null; end if;
-- If both gross sides are 0, no money moved -> nothing to require.
if coalesce(new.gross_usd,0) = 0 and coalesce(new.gross_lbp,0) = 0 then
return null;
end if;
select exists (
select 1 from app.float_movements
where ref_txn_id = new.id
) into ok;
if not ok then
raise exception 'money-transfer txn % (service %) has no float_movement leg',
new.id, new.service_code;
end if;
return null;
end;
$$;
drop trigger if exists trg_money_transfer_require_movement on app.transactions;
create constraint trigger trg_money_transfer_require_movement
after insert on app.transactions
deferrable initially deferred
for each row execute function app._money_transfer_require_movement();
-- End migration 0018 ----------------------------------------------------
@@ -0,0 +1,121 @@
-- =====================================================================
-- Migration 0019 — Cash-movement sign guard + fix record_cash_drop.
--
-- Two bugs in 0015 + 0002:
--
-- (a) record_cash_drop in 0015 inserted into app.cash_movements using
-- column names that do not exist (`movement_type`, `external_ref`)
-- and an enum value that doesn't exist (`safe_drop`). The real
-- schema is `type` / `note` and the enum value is `drop_to_safe`.
-- Worse, it inserted the drop amount as POSITIVE, which would make
-- `expected_close_usd` go UP when cash physically left the till.
--
-- (b) app.cash_movements has no constraint that the sign of `amount`
-- matches the movement `type`. A buggy or malicious insert with
-- type='drop_to_safe' amount=+1000 would silently increase the
-- expected drawer balance.
--
-- This migration:
-- 1. Adds a BEFORE INSERT trigger on app.cash_movements enforcing the
-- sign-vs-type rule.
-- 2. Replaces app.record_cash_drop with a correct implementation
-- using the real columns and a negative sign.
--
-- Threat-model rows: 7, 14, 22, 25.
-- =====================================================================
-- =====================================================================
-- Sign-vs-type guard
-- =====================================================================
create or replace function app._cash_mov_sign_check()
returns trigger
language plpgsql
as $$
begin
-- + cash into till
if new.type in ('opening_float','sale_in','fx_swap_in') then
if new.amount <= 0 then
raise exception 'cash_movements.type=% must have positive amount (got %)',
new.type, new.amount;
end if;
-- - cash out of till
elsif new.type in ('payout_out','drop_to_safe','bank_deposit',
'expense','fx_swap_out') then
if new.amount >= 0 then
raise exception 'cash_movements.type=% must have negative amount (got %)',
new.type, new.amount;
end if;
-- 'adjustment' is the only type that may legitimately go either way
-- (manager-approved correction). It must still be non-zero (already
-- enforced by the table CHECK).
end if;
return new;
end;
$$;
drop trigger if exists trg_cash_mov_sign_check on app.cash_movements;
create trigger trg_cash_mov_sign_check
before insert on app.cash_movements
for each row execute function app._cash_mov_sign_check();
-- =====================================================================
-- Replace record_cash_drop with a correct implementation.
-- Drops are negative cash_movement rows of type 'drop_to_safe'.
-- =====================================================================
create or replace function app.record_cash_drop(
p_shift_id uuid,
p_drop_usd numeric,
p_drop_lbp numeric,
p_notes text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_status app.shift_status;
v_user uuid;
begin
if p_drop_usd is null or p_drop_lbp is null
or p_drop_usd < 0 or p_drop_lbp < 0 then
raise exception 'drop amounts must be non-negative numbers';
end if;
if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
raise exception 'must drop > 0 in at least one currency';
end if;
select shop_id, status, user_id
into v_shop, v_status, v_user
from app.shifts
where id = p_shift_id;
if v_shop is null then
raise exception 'shift % not found', p_shift_id;
end if;
if v_status <> 'open' then
raise exception 'shift must be open to record a drop (got %)', v_status;
end if;
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
raise exception 'only the shift owner or a manager may record a drop';
end if;
if p_drop_usd > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe', 'USD', -p_drop_usd,
coalesce(p_notes, 'mid-day safe drop'));
end if;
if p_drop_lbp > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe', 'LBP', -p_drop_lbp,
coalesce(p_notes, 'mid-day safe drop'));
end if;
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
jsonb_build_object('shift_id', p_shift_id,
'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
-- End migration 0019 ----------------------------------------------------
@@ -0,0 +1,233 @@
-- =====================================================================
-- 0020_void_reverses_movements.sql
--
-- Up to and including 0019, app.void_transaction() only flipped the
-- transactions.status flag to 'voided'. The original cash_movements,
-- float_movements, stock_movements and voucher_inventory rows that the
-- record_* functions had posted stayed in place, so the till expected
-- balance, OMT/Whish float balance and stock-on-hand were never
-- corrected. A cashier could record a $500 OMT_SEND, pocket the $500,
-- then void the txn five minutes later and the books would still show
-- $500 received in the till.
--
-- This migration makes void a true accounting reversal:
-- * for every cash_movements row tied to the txn we post an opposite-
-- signed `adjustment` row (sign guard from 0019 allows either sign
-- for adjustment),
-- * for every float_movements row we post an opposite-signed row,
-- * for every stock_movements row we post an opposite type
-- (sale_out -> adjustment_in, return_in -> adjustment_out, etc.)
-- with the void approver as approved_by,
-- * any voucher_inventory marked sold by the txn is returned to
-- `in_stock` so the serial can be re-sold.
--
-- The reversals reference the same ref_txn_id so reconciliation views
-- and the receipts trail keep them paired with the original posting.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- Helper: post compensating cash + float + stock + voucher rows for a
-- transaction that is being voided. Returns nothing; the caller is
-- responsible for flipping the txn status itself.
-- ---------------------------------------------------------------------
create or replace function app._reverse_movements_for_txn(
p_txn_id uuid,
p_approver uuid,
p_reason text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
r_cash record;
r_flt record;
r_stk record;
v_reverse_type app.stock_movement_type;
v_note text;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then
raise exception 'reverse: txn % not found', p_txn_id;
end if;
v_note := 'void reversal: ' || coalesce(p_reason, '');
-- ----- Cash legs --------------------------------------------------
-- Re-post each existing cash_movements row with opposite sign as an
-- 'adjustment' (the only cash_movement_type that the 0019 sign guard
-- lets carry either sign).
for r_cash in
select id, shift_id, currency, amount
from app.cash_movements
where ref_txn_id = p_txn_id
and type <> 'adjustment' -- don't reverse prior reversals
loop
insert into app.cash_movements(
shift_id, type, currency, amount, ref_txn_id, note, created_by
) values (
r_cash.shift_id,
'adjustment'::app.cash_movement_type,
r_cash.currency,
-r_cash.amount,
p_txn_id,
v_note,
p_approver
);
end loop;
-- ----- Float legs -------------------------------------------------
for r_flt in
select id, float_id, shift_id, amount
from app.float_movements
where ref_txn_id = p_txn_id
and coalesce(reason,'') not like 'void reversal%'
loop
insert into app.float_movements(
float_id, shift_id, amount, ref_txn_id, reason, created_by
) values (
r_flt.float_id,
r_flt.shift_id,
-r_flt.amount,
p_txn_id,
v_note,
p_approver
);
end loop;
-- ----- Stock legs -------------------------------------------------
-- For physical goods sold via record_goods_sale, reverse the
-- sale_out by posting an adjustment_in of equal magnitude (positive),
-- and vice versa for any in-bound rows tied to this txn.
for r_stk in
select id, sku, shop_id, shift_id, type, qty_delta, ref_lot_id
from app.stock_movements
where ref_txn_id = p_txn_id
and type not in ('adjustment_in','adjustment_out')
loop
if r_stk.qty_delta < 0 then
v_reverse_type := 'adjustment_in'::app.stock_movement_type;
else
v_reverse_type := 'adjustment_out'::app.stock_movement_type;
end if;
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta,
ref_txn_id, ref_lot_id, approved_by, reason, created_by
) values (
r_stk.sku, r_stk.shop_id, r_stk.shift_id,
v_reverse_type,
-r_stk.qty_delta,
p_txn_id, r_stk.ref_lot_id,
p_approver,
v_note,
p_approver
);
end loop;
-- ----- Voucher serials -------------------------------------------
-- Any voucher marked sold by this txn returns to in_stock so it can
-- be sold again. (The voucher_status_consistency CHECK clears
-- sold_txn_id / sold_at when status becomes 'in_stock'.)
update app.voucher_inventory
set status = 'in_stock',
sold_txn_id = null,
sold_at = null,
status_changed_by = p_approver,
status_change_reason = v_note
where sold_txn_id = p_txn_id
and status = 'sold';
end;
$$;
revoke all on function app._reverse_movements_for_txn(uuid, uuid, text) from public;
-- Internal helper only — callable from void_transaction (security definer).
-- ---------------------------------------------------------------------
-- Re-define void_transaction to reverse movements before flipping
-- status. We keep the same signature as 0003 so the existing UI calls
-- continue to work.
-- ---------------------------------------------------------------------
create or replace function app.void_transaction(
p_txn_id uuid,
p_reason text,
p_approver_pin text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
s app.shifts%rowtype;
window_min int;
needs_manager boolean;
v_approver uuid;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'transaction not found'; end if;
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'a reason of at least 5 characters is required';
end if;
select * into s from app.shifts where id = t.shift_id;
if s.status <> 'open' then
raise exception 'cannot void a transaction whose shift is no longer open';
end if;
select coalesce(value::int, 10) into window_min
from app.system_settings where key = 'void_self_window_minutes';
needs_manager := (auth.uid() <> t.user_id)
or (now() - t.created_at > make_interval(mins => window_min));
if needs_manager then
if not app.has_role_in_shop(t.shop_id, 'manager') then
raise exception 'manager approval required to void this transaction';
end if;
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
raise exception 'manager PIN required and must be valid';
end if;
end if;
v_approver := auth.uid();
-- Reverse the money / stock / voucher legs FIRST. If any of these
-- inserts fails (e.g. stock would go negative because more vouchers
-- have been sold from the lot since), the whole void is rolled back
-- and the books stay consistent.
if t.status = 'completed' then
perform app._reverse_movements_for_txn(p_txn_id, v_approver, p_reason);
end if;
-- Flip the status (only this function may UPDATE app.transactions).
perform set_config('app.txn_internal', 'on', true);
update app.transactions
set status = 'voided',
voided_at = now(),
voided_by = v_approver,
void_reason = p_reason,
void_approved_by = case when needs_manager then v_approver else null end
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
-- Recompute the row's hash so the chain reflects the new state.
perform set_config('app.txn_internal', 'on', true);
update app.transactions tt
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
perform app.log_auth_event('txn_voided', t.shop_id, null,
jsonb_build_object('txn_id', p_txn_id,
'manager_path', needs_manager,
'reversed', t.status = 'completed'));
end;
$$;
revoke all on function app.void_transaction(uuid, text, text) from public;
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
+230
View File
@@ -0,0 +1,230 @@
-- =====================================================================
-- 0021_fee_schedule.sql
--
-- Today the cashier types fee_usd / fee_lbp / commission_usd / commission_lbp
-- by hand on every OMT_SEND, OMT_RECEIVE, WU_*, WHISH_SEND, EDL_BILL,
-- recharge and goods sale. There is no server-side anchor for what the
-- fee is *supposed* to be, which means a cashier can:
--
-- * pocket part of the customer's fee by recording a smaller fee
-- than they collected,
-- * record a larger fee than the official sheet to siphon shop
-- commission, then refund the excess to themselves later.
--
-- This migration adds an opt-in per-shop fee schedule:
--
-- app.fee_schedule(shop_id, service_code, currency,
-- min_amount, max_amount,
-- fee_fixed, fee_pct,
-- commission_fixed, commission_pct,
-- tolerance)
--
-- and a deferred constraint trigger that, *only when at least one row
-- exists for the shop+service+currency*, validates the fee/commission
-- on the transaction against the bracket the gross falls into. Shops
-- that don't seed the table keep working exactly as before.
-- =====================================================================
set search_path = app, public;
create table if not exists app.fee_schedule (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null references app.services(code),
currency app.currency_code not null,
-- Inclusive lower bound, exclusive upper bound (use a very large
-- max_amount for the "and above" bracket).
min_amount numeric(18,2) not null check (min_amount >= 0),
max_amount numeric(18,2) not null,
fee_fixed numeric(18,2) not null default 0 check (fee_fixed >= 0),
fee_pct numeric(7,4) not null default 0 check (fee_pct >= 0 and fee_pct <= 100),
commission_fixed numeric(18,2) not null default 0 check (commission_fixed >= 0),
commission_pct numeric(7,4) not null default 0 check (commission_pct >= 0 and commission_pct <= 100),
-- Allowed absolute tolerance between scheduled and recorded fee. Set
-- non-zero for services priced in LBP rounded to nearest 1000.
tolerance numeric(18,2) not null default 0 check (tolerance >= 0),
effective_from timestamptz not null default now(),
effective_to timestamptz,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
check (max_amount > min_amount),
check (effective_to is null or effective_to > effective_from)
);
create index if not exists idx_fee_schedule_lookup
on app.fee_schedule(shop_id, service_code, currency, effective_from desc);
alter table app.fee_schedule enable row level security;
alter table app.fee_schedule force row level security;
-- Owners/managers of the shop can read and edit. Cashiers can read.
drop policy if exists fee_schedule_select on app.fee_schedule;
create policy fee_schedule_select on app.fee_schedule
for select using (
app.has_any_role_in_shop(shop_id,
array['cashier','manager','owner']::app.business_role[])
);
drop policy if exists fee_schedule_write on app.fee_schedule;
create policy fee_schedule_write on app.fee_schedule
for all using (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
) with check (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
);
-- ---------------------------------------------------------------------
-- Append-only on history: once published, a row's bracket cannot be
-- mutated; managers must close it (set effective_to) and insert a new
-- one. This preserves a clean audit trail of what fees were in force.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_immutable()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'fee_schedule rows are append-only; close them with effective_to';
end if;
-- Only effective_to may move forward (close a bracket). Everything
-- else must stay put.
if (old.shop_id, old.service_code, old.currency, old.min_amount,
old.max_amount, old.fee_fixed, old.fee_pct,
old.commission_fixed, old.commission_pct, old.tolerance,
old.effective_from)
is distinct from
(new.shop_id, new.service_code, new.currency, new.min_amount,
new.max_amount, new.fee_fixed, new.fee_pct,
new.commission_fixed, new.commission_pct, new.tolerance,
new.effective_from)
then
raise exception 'fee_schedule columns are immutable; close the row and insert a new one';
end if;
if old.effective_to is not null then
raise exception 'fee_schedule row already closed';
end if;
if new.effective_to is null or new.effective_to <= now() - interval '1 minute' then
raise exception 'effective_to must be set to a current/future timestamp to close a bracket';
end if;
return new;
end;
$$;
drop trigger if exists trg_fee_schedule_immutable on app.fee_schedule;
create trigger trg_fee_schedule_immutable
before update or delete on app.fee_schedule
for each row execute function app._fee_schedule_immutable();
-- ---------------------------------------------------------------------
-- Lookup helper: returns the active bracket for a (shop, service,
-- currency, gross). Returns NULL if no schedule applies.
-- ---------------------------------------------------------------------
create or replace function app.compute_scheduled_fee(
p_shop uuid,
p_service text,
p_currency app.currency_code,
p_gross numeric
) returns table (
expected_fee numeric,
expected_commission numeric,
tolerance numeric,
bracket_id uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
select
coalesce(fs.fee_fixed,0) + coalesce(fs.fee_pct,0) / 100.0 * p_gross,
coalesce(fs.commission_fixed,0) + coalesce(fs.commission_pct,0) / 100.0 * p_gross,
fs.tolerance,
fs.id
from app.fee_schedule fs
where fs.shop_id = p_shop
and fs.service_code = p_service
and fs.currency = p_currency
and fs.min_amount <= p_gross
and fs.max_amount > p_gross
and fs.effective_from <= now()
and (fs.effective_to is null or fs.effective_to > now())
order by fs.effective_from desc
limit 1;
$$;
revoke all on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) from public;
grant execute on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) to authenticated;
-- ---------------------------------------------------------------------
-- Constraint trigger: validates fee/commission against the schedule
-- when a matching bracket exists. Runs on INSERT (transactions are
-- append-only). Fired DEFERRED so the txn row is fully populated before
-- we look it up.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
rec_usd record;
rec_lbp record;
diff numeric;
begin
-- Only validate completed money transactions; refunds, voids and
-- non-monetary services bypass.
if new.status <> 'completed' then return null; end if;
if new.service_code in ('REFUND','OPENING_FLOAT','SAFE_DROP','BANK_DEPOSIT')
then return null; end if;
if coalesce(new.gross_usd, 0) > 0 then
select * into rec_usd
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'USD'::app.currency_code, new.gross_usd);
if rec_usd.bracket_id is not null then
diff := abs(coalesce(new.fee_usd,0) - rec_usd.expected_fee);
if diff > rec_usd.tolerance then
raise exception
'fee_usd % deviates from schedule % (tolerance %, bracket %)',
new.fee_usd, rec_usd.expected_fee, rec_usd.tolerance, rec_usd.bracket_id;
end if;
diff := abs(coalesce(new.commission_usd,0) - rec_usd.expected_commission);
if diff > rec_usd.tolerance then
raise exception
'commission_usd % deviates from schedule % (tolerance %, bracket %)',
new.commission_usd, rec_usd.expected_commission, rec_usd.tolerance, rec_usd.bracket_id;
end if;
end if;
end if;
if coalesce(new.gross_lbp, 0) > 0 then
select * into rec_lbp
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'LBP'::app.currency_code, new.gross_lbp);
if rec_lbp.bracket_id is not null then
diff := abs(coalesce(new.fee_lbp,0) - rec_lbp.expected_fee);
if diff > rec_lbp.tolerance then
raise exception
'fee_lbp % deviates from schedule % (tolerance %, bracket %)',
new.fee_lbp, rec_lbp.expected_fee, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
diff := abs(coalesce(new.commission_lbp,0) - rec_lbp.expected_commission);
if diff > rec_lbp.tolerance then
raise exception
'commission_lbp % deviates from schedule % (tolerance %, bracket %)',
new.commission_lbp, rec_lbp.expected_commission, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_fee_schedule_check on app.transactions;
create constraint trigger trg_fee_schedule_check
after insert on app.transactions
deferrable initially deferred
for each row execute function app._fee_schedule_check();
@@ -0,0 +1,318 @@
-- =====================================================================
-- 0022_atomic_sale_coupling.sql
--
-- Today the cashier UI calls record_recharge() and record_goods_sale()
-- but never separately calls sell_voucher() or posts the e-float debit
-- / sale_out stock movement. The deferred constraint triggers from
-- 0005 (_recharge_require_movement, _goods_sale_require_movement)
-- therefore reject every commit at end-of-transaction… *unless* the
-- trigger never fires because the RLS-protected detail row blocked
-- the INSERT, in which case the txn header silently survives without
-- any inventory or float impact.
--
-- Either way the books are wrong: a "sold" voucher serial keeps
-- showing as in_stock, the e-float balance does not drop, and a phone
-- sold off the shelf does not decrement stock_on_hand.
--
-- This migration folds the inventory/float legs INTO the record_*
-- functions themselves, in the same SECURITY DEFINER transaction:
--
-- record_recharge -> if voucher_serial: mark voucher sold + post
-- sale_out (-1) for the voucher SKU.
-- else (e-recharge): post a negative float_movement
-- for ALFA_ERECHARGE / TOUCH_ERECHARGE / OGERO_ERECHARGE
-- sized at unit_cost_usd (or gross_usd as fallback).
--
-- record_goods_sale -> post a sale_out stock_movement for the SKU
-- with -p_qty.
--
-- Both are wrapped in a single transaction so either everything posts
-- or the whole sale rolls back. The deferred coupling triggers from
-- 0005 then pass naturally.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- Internal helper: mark a voucher sold + post sale_out, callable from
-- inside record_recharge. Mirrors app.sell_voucher() but does not check
-- auth.uid() against the txn owner because record_recharge is itself
-- security definer running as the cashier who created the txn.
-- ---------------------------------------------------------------------
create or replace function app._sell_voucher_internal(
p_txn_id uuid,
p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v app.voucher_inventory%rowtype;
t app.transactions%rowtype;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
select * into v from app.voucher_inventory
where serial = p_serial for update;
if v.serial is null then
raise exception 'voucher % not found', p_serial;
end if;
if v.shop_id <> t.shop_id then
raise exception 'voucher % belongs to a different shop', p_serial;
end if;
if v.status <> 'in_stock' then
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
end if;
update app.voucher_inventory
set status = 'sold',
sold_txn_id = p_txn_id,
sold_at = now(),
status_changed_by = auth.uid()
where serial = p_serial;
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
) values (
v.sku, v.shop_id, t.shift_id,
'sale_out'::app.stock_movement_type,
-1, p_txn_id, 'voucher ' || p_serial
);
end;
$$;
revoke all on function app._sell_voucher_internal(uuid, text) from public;
-- ---------------------------------------------------------------------
-- Internal helper: post the e-float debit for an e-recharge.
-- ---------------------------------------------------------------------
create or replace function app._erecharge_post_float(
p_txn_id uuid,
p_operator text,
p_amount numeric -- positive cost; the row will be negated
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_provider app.float_provider;
v_float uuid;
t app.transactions%rowtype;
begin
if p_amount is null or p_amount <= 0 then
raise exception 'e-recharge cost must be > 0 (got %)', p_amount;
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;
v_provider := case upper(p_operator)
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
else null
end;
if v_provider is null then
-- Unmapped operator (IDM, CYBERIA, TERRANET): fall back to OMT_DIGITAL
-- so the recharge_require_movement trigger sees a negative leg.
v_provider := 'OMT_DIGITAL'::app.float_provider;
end if;
v_float := app._get_or_create_float(t.shop_id, v_provider, 'USD'::app.currency_code);
insert into app.float_movements(
float_id, shift_id, amount, ref_txn_id, reason
) values (
v_float, t.shift_id, -p_amount, p_txn_id,
'e-recharge ' || coalesce(p_operator, '?')
);
end;
$$;
revoke all on function app._erecharge_post_float(uuid, text, numeric) from public;
-- ---------------------------------------------------------------------
-- Re-define record_recharge to fold in voucher / e-float posting, and
-- the cash leg via the helper added in 0018.
-- ---------------------------------------------------------------------
create or replace function app.record_recharge(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_operator text, p_msisdn text, p_product_code text,
p_voucher_serial text, p_e_recharge_ref text,
p_unit_face_usd numeric, p_unit_cost_usd numeric,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_serial text := nullif(btrim(p_voucher_serial),'');
v_eref text := nullif(btrim(p_e_recharge_ref),'');
v_cost_usd numeric;
begin
if v_serial is null and v_eref is null then
raise exception 'either voucher_serial or e_recharge_ref is required';
end if;
if v_serial is not null and v_eref is not null then
raise exception 'pass either voucher_serial OR e_recharge_ref, not both';
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_operator, v_serial, null, p_msisdn, null, p_notes);
insert into app.recharge_details(
txn_id, operator, msisdn, product_code,
voucher_serial, e_recharge_provider_ref,
unit_face_value_usd, unit_cost_usd
) values (
v_txn, p_operator, p_msisdn, p_product_code,
v_serial, v_eref,
p_unit_face_usd, p_unit_cost_usd
);
-- ---- inventory / float coupling --------------------------------
if v_serial is not null then
perform app._sell_voucher_internal(v_txn, v_serial);
else
-- e-recharge: prefer recorded unit_cost_usd, fall back to gross_usd.
v_cost_usd := coalesce(nullif(p_unit_cost_usd,0), p_gross_usd);
perform app._erecharge_post_float(v_txn, p_operator, v_cost_usd);
end if;
-- ---- cash leg (re-uses helper from 0018) -----------------------
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) from public;
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) to authenticated;
-- ---------------------------------------------------------------------
-- Re-define record_goods_sale to fold in the sale_out stock movement
-- and the cash leg in the same transaction.
-- ---------------------------------------------------------------------
create or replace function app.record_goods_sale(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_sku text, p_qty integer,
p_unit_cost_usd numeric, p_unit_price_usd numeric,
p_serial_number text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
begin
if p_qty is null or p_qty <= 0 then
raise exception 'qty must be > 0';
end if;
if p_sku is null or btrim(p_sku) = '' then
raise exception 'sku required';
end if;
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.goods_sale_details(
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
) values (
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
);
-- Stock leg. The 0005 _stock_mov_before_insert trigger validates the
-- ref_txn_id points at a completed sale in the same shop, and the
-- _stock_on_hand_apply trigger refuses to go negative.
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
) values (
p_sku, p_shop, (select shift_id from app.transactions where id = v_txn),
'sale_out'::app.stock_movement_type,
-p_qty,
v_txn,
case when p_serial_number is not null
then 'goods sale serial=' || p_serial_number
else 'goods sale' end
);
-- Cash leg.
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0),
coalesce(p_gross_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
-- ---------------------------------------------------------------------
-- Repair sales also put cash in the till (parts + labour). The original
-- record_repair from 0013 inserts only the txn header + repair detail
-- and never posts cash, so REPAIR variances were silently absorbed by
-- the next cashier's drop. Wrap the existing function so it posts cash.
-- ---------------------------------------------------------------------
create or replace function app.record_repair(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_device_type text, p_device_imei text,
p_issue_summary text, p_warranty_days integer,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.repair_details(
txn_id, device_type, device_imei, issue_summary, warranty_days
) values (
v_txn, p_device_type, p_device_imei, p_issue_summary, p_warranty_days
);
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0),
coalesce(p_gross_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
@@ -0,0 +1,296 @@
-- =====================================================================
-- 0023_fx_rates_and_swap.sql
--
-- Two related holes around foreign exchange:
--
-- (1) The cashier types an arbitrary `fx_rate_used` on every USD/LBP
-- transaction. Nothing on the server compares it to the daily
-- posted rate. A cashier can rate a $100 sale at 1 USD = 90,000 LBP
-- while the till uses 1 USD = 89,500 LBP and pocket the spread.
--
-- (2) `cash_movement_type` has 'fx_swap_in' and 'fx_swap_out' but no
-- function posts them as a matched pair. A cashier swapping $100
-- out of the till for 8.95M LBP today does it manually with two
-- uncoupled cash_movements rows; the sign-guard added in 0019
-- catches gross sign mistakes but not amount mismatches.
--
-- This migration:
-- * adds `app.fx_rates(shop_id, effective_from, usd_to_lbp_rate,
-- tolerance_pct)` — append-only history of the shop's posted rate.
-- * adds `app.compute_fx_window(shop, effective)` returning the
-- accepted band [low, high] for the currently-active rate.
-- * adds a constraint trigger on `app.transactions` that, only when
-- a rate is published for the shop, enforces fx_rate_used falls
-- within the band whenever both gross_usd and gross_lbp are non-zero
-- (genuine cross-currency txn) OR for explicit FX swaps.
-- * adds `app.record_fx_swap(shop, till, p_usd_amount, p_lbp_amount,
-- p_fx_rate)` which posts both legs atomically and refuses the call
-- unless |p_usd_amount * fx_rate - p_lbp_amount| <= 1 LBP.
--
-- Shops that don't seed `fx_rates` keep working unchanged.
-- =====================================================================
set search_path = app, public;
create table if not exists app.fx_rates (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
effective_from timestamptz not null default now(),
effective_to timestamptz,
-- Number of LBP per 1 USD (e.g. 89500).
usd_to_lbp_rate numeric(14,2) not null check (usd_to_lbp_rate > 0),
-- Allowed deviation either side of the posted rate, as a percent
-- (e.g. 1.0 = ±1%). Defaults to 0.5%.
tolerance_pct numeric(6,3) not null default 0.5
check (tolerance_pct >= 0 and tolerance_pct <= 25),
note text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
check (effective_to is null or effective_to > effective_from)
);
create index if not exists idx_fx_rates_lookup
on app.fx_rates(shop_id, effective_from desc);
alter table app.fx_rates enable row level security;
alter table app.fx_rates force row level security;
drop policy if exists fx_rates_select on app.fx_rates;
create policy fx_rates_select on app.fx_rates
for select using (
app.has_any_role_in_shop(shop_id,
array['cashier','manager','owner']::app.business_role[])
);
drop policy if exists fx_rates_write on app.fx_rates;
create policy fx_rates_write on app.fx_rates
for all using (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
) with check (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
);
-- Append-only on history: only effective_to may be moved forward. Same
-- pattern as fee_schedule in 0021.
create or replace function app._fx_rates_immutable()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'fx_rates is append-only';
end if;
if (old.shop_id, old.usd_to_lbp_rate, old.tolerance_pct, old.effective_from)
is distinct from
(new.shop_id, new.usd_to_lbp_rate, new.tolerance_pct, new.effective_from)
then
raise exception 'fx_rates columns are immutable; close the row and insert a new one';
end if;
if old.effective_to is not null then
raise exception 'fx_rates row already closed';
end if;
if new.effective_to is null then
raise exception 'effective_to must be set to close an fx_rates row';
end if;
return new;
end;
$$;
drop trigger if exists trg_fx_rates_immutable on app.fx_rates;
create trigger trg_fx_rates_immutable
before update or delete on app.fx_rates
for each row execute function app._fx_rates_immutable();
-- ---------------------------------------------------------------------
-- Lookup helper: return the active rate band for a shop right now.
-- ---------------------------------------------------------------------
create or replace function app.current_fx_band(p_shop uuid)
returns table (
rate numeric,
band_low numeric,
band_high numeric,
tolerance_pct numeric,
rate_id uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
select
f.usd_to_lbp_rate,
f.usd_to_lbp_rate * (1 - f.tolerance_pct / 100.0),
f.usd_to_lbp_rate * (1 + f.tolerance_pct / 100.0),
f.tolerance_pct,
f.id
from app.fx_rates f
where f.shop_id = p_shop
and f.effective_from <= now()
and (f.effective_to is null or f.effective_to > now())
order by f.effective_from desc
limit 1;
$$;
revoke all on function app.current_fx_band(uuid) from public;
grant execute on function app.current_fx_band(uuid) to authenticated;
-- ---------------------------------------------------------------------
-- Constraint trigger on app.transactions:
-- when a posted rate exists for the shop, fx_rate_used must lie within
-- the band whenever the txn is a genuine USD/LBP cross-currency event.
-- ---------------------------------------------------------------------
create or replace function app._txn_fx_rate_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
band record;
begin
-- Skip non-money / void rows.
if new.status <> 'completed' then return null; end if;
if coalesce(new.fx_rate_used, 0) = 0 then return null; end if;
-- Only police txns that actually mix the two currencies, or where
-- the cashier deliberately recorded an fx_rate (e.g. payment in USD,
-- gross in LBP).
if not (coalesce(new.gross_usd,0) <> 0 and coalesce(new.gross_lbp,0) <> 0)
and new.service_code <> 'FX_SWAP'
then
-- Some single-currency txns also store the day's rate for
-- reporting; still validate it against the band so a wildly wrong
-- value can't slip through.
null;
end if;
select * into band from app.current_fx_band(new.shop_id);
if band.rate_id is null then
return null; -- shop hasn't published a rate yet
end if;
if new.fx_rate_used < band.band_low or new.fx_rate_used > band.band_high then
raise exception
'fx_rate_used % outside posted band [%, %] (rate %, tolerance % %%)',
new.fx_rate_used, band.band_low, band.band_high,
band.rate, band.tolerance_pct;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_fx_rate_check on app.transactions;
create constraint trigger trg_txn_fx_rate_check
after insert on app.transactions
deferrable initially deferred
for each row execute function app._txn_fx_rate_check();
-- ---------------------------------------------------------------------
-- record_fx_swap: post the two cash_movements legs atomically.
-- direction:
-- p_usd_out > 0 means USD leaves the till and LBP comes in
-- -> fx_swap_out USD, fx_swap_in LBP
-- p_usd_out < 0 means USD comes into the till and LBP leaves
-- -> fx_swap_in USD, fx_swap_out LBP
-- The amounts on both sides must agree to within 1 LBP at p_fx_rate,
-- and p_fx_rate must lie within the posted band (when one exists).
-- ---------------------------------------------------------------------
do $$ begin
-- Add FX_SWAP service code if not already present, so the txn header
-- has a real service to attach to (the recorded txn carries no
-- product detail row).
if not exists (select 1 from app.services where code = 'FX_SWAP') then
insert into app.services(code, name, category)
values ('FX_SWAP', 'Currency Exchange', 'cash_ops');
end if;
end $$;
create or replace function app.record_fx_swap(
p_shop uuid,
p_till uuid,
p_usd_out numeric, -- + USD leaves till, - USD enters till
p_lbp_in numeric, -- + LBP enters till when usd_out>0, must be opposite sign
p_fx_rate numeric,
p_notes text default null
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid;
v_diff numeric;
v_txn uuid;
band record;
begin
if p_usd_out is null or p_lbp_in is null or p_fx_rate is null or p_fx_rate <= 0 then
raise exception 'usd_out, lbp_in and positive fx_rate are required';
end if;
if p_usd_out = 0 then
raise exception 'usd_out cannot be 0';
end if;
if sign(p_usd_out) = sign(p_lbp_in) then
raise exception 'usd_out and lbp_in must have opposite signs (one in, one out)';
end if;
-- Sanity: |usd_out| * rate must equal |lbp_in| within 1 LBP.
v_diff := abs(abs(p_usd_out) * p_fx_rate - abs(p_lbp_in));
if v_diff > 1 then
raise exception
'fx swap mismatch: |usd_out|*rate = % but |lbp_in| = % (diff %)',
abs(p_usd_out) * p_fx_rate, abs(p_lbp_in), v_diff;
end if;
-- Rate band (only enforced when a rate is published).
select * into band from app.current_fx_band(p_shop);
if band.rate_id is not null
and (p_fx_rate < band.band_low or p_fx_rate > band.band_high)
then
raise exception
'fx_rate % outside posted band [%, %] (rate %, tolerance % %%)',
p_fx_rate, band.band_low, band.band_high, band.rate, band.tolerance_pct;
end if;
-- Caller must have an open shift on this till.
select id into v_shift from app.shifts
where till_id = p_till and shop_id = p_shop
and user_id = auth.uid() and status = 'open'
order by opened_at desc limit 1;
if v_shift is null then
raise exception 'no open shift for this till';
end if;
-- Create a header txn (gross_usd/lbp = 0 — money does not enter or
-- leave the shop, just changes currency). The fx_rate is recorded.
v_txn := app._insert_txn(p_shop, p_till, 'FX_SWAP', 'cash_usd'::app.payment_method,
0, 0, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, null, p_notes);
-- USD leg.
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (
v_shift,
case when p_usd_out > 0
then 'fx_swap_out'::app.cash_movement_type
else 'fx_swap_in'::app.cash_movement_type end,
'USD'::app.currency_code,
-p_usd_out, -- p_usd_out is the OUTflow amount
v_txn,
'fx swap'
);
-- LBP leg.
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (
v_shift,
case when p_lbp_in > 0
then 'fx_swap_in'::app.cash_movement_type
else 'fx_swap_out'::app.cash_movement_type end,
'LBP'::app.currency_code,
p_lbp_in,
v_txn,
'fx swap'
);
return v_txn;
end;
$$;
revoke all on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) from public;
grant execute on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) to authenticated;
@@ -0,0 +1,331 @@
-- =====================================================================
-- 0024_idempotency_and_self_deal.sql
--
-- Two related fraud vectors not yet closed:
--
-- (A) Idempotency / replay. Today the cashier can post the same OMT
-- payout code twice in the same shift and pocket the difference,
-- or post the same WU MTCN twice and let the second one fail to
-- reconcile silently. Nothing on the server enforces uniqueness
-- of `(shop_id, external_ref_provider, external_ref)` for active
-- money-transfer transactions.
--
-- (B) Self-deal. A cashier processing transfers on their own KYC ID
-- (or as the named beneficiary of a payout, or as the sender of
-- a high-value send to themselves) is the classic skim pattern
-- across all Lebanese MFS shops. The DB has all the data — the
-- cashier's user_profiles row, plus sender_id_number /
-- beneficiary_id_number on the detail row — but never compares
-- them.
--
-- This migration:
-- * adds nullable `id_type` / `id_number` / `phone_kyc` columns to
-- `app.user_profiles` (the cashier's own KYC),
-- * unique index on (shop_id, external_ref_provider, external_ref)
-- covering only completed (or pending) money-transfer service
-- codes,
-- * deferred constraint trigger that rejects an OMT/WU/Whish/bill
-- txn whose sender or beneficiary ID matches the cashier's own
-- KYC, unless an `app.system_settings` flag explicitly allows it
-- AND the txn is approved by a manager.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- (A) idempotent external_ref
-- ---------------------------------------------------------------------
-- A *partial unique* index limited to:
-- * completed or pending status (voided rows can re-use a code if
-- the original was void-reversed, which is desired),
-- * money-transfer / bill service codes (recharges and goods sales
-- don't carry meaningful external_ref uniqueness).
create unique index if not exists ux_txn_external_ref_active
on app.transactions (shop_id, external_ref_provider, external_ref)
where external_ref is not null
and external_ref_provider is not null
and status <> 'voided'
and service_code in (
'OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE',
'WHISH_SEND','OMT_BILL','EDL_BILL'
);
-- ---------------------------------------------------------------------
-- (B) self-deal: extend user_profiles with cashier KYC
-- ---------------------------------------------------------------------
alter table app.user_profiles
add column if not exists id_type app.id_doc_type,
add column if not exists id_number text,
add column if not exists phone_kyc text;
create index if not exists idx_user_profiles_kyc_id
on app.user_profiles(id_type, id_number)
where id_number is not null;
-- Allow a manager to bypass self-deal blocking for a specific txn by
-- setting this knob; default is to block.
insert into app.system_settings(key, value)
values ('self_deal_block_enabled', 'true')
on conflict (key) do nothing;
-- ---------------------------------------------------------------------
-- Helper: does an ID belong to the cashier who created the txn?
-- ---------------------------------------------------------------------
create or replace function app._is_cashier_self(
p_user_id uuid,
p_id_type app.id_doc_type,
p_id_number text,
p_phone text
) returns boolean
language sql
stable
security definer
set search_path = app, public
as $$
select exists (
select 1 from app.user_profiles up
where up.user_id = p_user_id
and (
(p_id_number is not null
and up.id_number is not null
and up.id_type = p_id_type
and lower(btrim(up.id_number)) = lower(btrim(p_id_number)))
or (p_phone is not null
and up.phone_kyc is not null
and regexp_replace(up.phone_kyc, '\D', '', 'g')
= regexp_replace(p_phone, '\D', '', 'g'))
)
);
$$;
revoke all on function app._is_cashier_self(uuid, app.id_doc_type, text, text) from public;
-- ---------------------------------------------------------------------
-- Constraint trigger: fired AFTER INSERT on the detail rows that carry
-- counter-party identity. Each branch checks the txn owner against
-- the recorded sender / beneficiary KYC.
-- ---------------------------------------------------------------------
create or replace function app._omt_send_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone)
then
raise exception
'self-deal blocked: cashier (% ) is the SENDER on txn % — manager must process this transfer',
t.user_id, new.txn_id;
end if;
-- A cashier sending to themselves as beneficiary is also self-deal.
-- We only have name+phone for the beneficiary on send rows, so match
-- on phone (most reliable) when present.
if new.beneficiary_phone is not null
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the BENEFICIARY phone on txn %',
new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_omt_send_self_deal on app.omt_send_details;
create constraint trigger trg_omt_send_self_deal
after insert on app.omt_send_details
deferrable initially deferred
for each row execute function app._omt_send_self_deal_check();
create or replace function app._omt_receive_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id,
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn % — manager must process',
new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_omt_receive_self_deal on app.omt_receive_details;
create constraint trigger trg_omt_receive_self_deal
after insert on app.omt_receive_details
deferrable initially deferred
for each row execute function app._omt_receive_self_deal_check();
-- ---------------------------------------------------------------------
-- Manager-only knob: temporarily allow a single self-deal transfer
-- (e.g. owner sending themselves their own salary). Auto-resets after
-- one INSERT via a session GUC.
-- ---------------------------------------------------------------------
create or replace function app.manager_allow_next_self_deal(
p_manager_pin text,
p_shop uuid
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
perform set_config('app.self_deal_override', 'on', true); -- session GUC
perform app.log_auth_event('self_deal_override_granted', p_shop, null, '{}'::jsonb);
end;
$$;
revoke all on function app.manager_allow_next_self_deal(text, uuid) from public;
grant execute on function app.manager_allow_next_self_deal(text, uuid) to authenticated;
-- Wire the override into the self-deal checkers.
create or replace function app._self_deal_overridden()
returns boolean
language sql
stable
as $$
select coalesce(current_setting('app.self_deal_override', true), 'off') = 'on';
$$;
create or replace function app._omt_send_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
if app._self_deal_overridden() then
perform set_config('app.self_deal_override', 'off', true);
return null;
end if;
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone) then
raise exception
'self-deal blocked: cashier is the SENDER on txn %', new.txn_id;
end if;
if new.beneficiary_phone is not null
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the BENEFICIARY phone on txn %', new.txn_id;
end if;
return null;
end;
$$;
create or replace function app._omt_receive_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
if app._self_deal_overridden() then
perform set_config('app.self_deal_override', 'off', true);
return null;
end if;
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id,
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn %', new.txn_id;
end if;
return null;
end;
$$;
-- ---------------------------------------------------------------------
-- Convenience RPC for the manager UI to set/update a cashier's KYC.
-- ---------------------------------------------------------------------
create or replace function app.set_user_kyc(
p_user_id uuid,
p_shop uuid,
p_id_type app.id_doc_type,
p_id_number text,
p_phone_kyc text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if p_id_number is null or btrim(p_id_number) = '' then
raise exception 'id_number required';
end if;
-- the user must actually be assigned to this shop
if not exists(
select 1 from app.user_shop_assignments
where user_id = p_user_id and shop_id = p_shop
) then
raise exception 'user is not assigned to that shop';
end if;
update app.user_profiles
set id_type = p_id_type,
id_number = btrim(p_id_number),
phone_kyc = nullif(btrim(p_phone_kyc),'')
where user_id = p_user_id;
perform app.log_auth_event('user_kyc_updated', p_shop, null,
jsonb_build_object('user_id', p_user_id, 'id_type', p_id_type));
end;
$$;
revoke all on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) from public;
grant execute on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) to authenticated;
@@ -0,0 +1,311 @@
-- =====================================================================
-- 0025_safe_and_bank_ledger.sql
--
-- The book has two cash buckets that already appear as cash_movement
-- types but have no first-class ledger:
-- drop_to_safe — cashier moves notes from the till to the shop safe
-- bank_deposit — owner / manager takes notes from the safe to the bank
--
-- Without a paired ledger the safe balance is essentially "trust me",
-- and a manager can quietly skim from the safe without leaving any
-- audit trail. record_cash_drop (fixed in 0019) only debits the till
-- side; the safe side is implicit.
--
-- This migration:
-- * adds `app.safes` (one logical safe per shop) and
-- `app.safe_movements` (append-only ledger),
-- * extends `record_cash_drop` to also CREDIT the safe in the same
-- transaction (caller still uses the same signature),
-- * adds `app.record_bank_deposit(shop, currency, amount, bank_ref,
-- deposit_slip_url, notes)` that DEBITS the safe and creates the
-- `bank_deposit` cash_movements row + an audit row in
-- `app.bank_deposits`,
-- * exposes a view `v_safe_balance` for the owner dashboard.
-- =====================================================================
set search_path = app, public;
create table if not exists app.safes (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
name text not null default 'Main Safe',
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique(shop_id, name)
);
-- One default safe per shop (idempotent).
insert into app.safes(shop_id, name)
select s.id, 'Main Safe' from app.shops s
on conflict do nothing;
-- Auto-create a Main Safe whenever a new shop is added.
create or replace function app._auto_create_safe()
returns trigger language plpgsql
security definer
set search_path = app, public
as $$
begin
insert into app.safes(shop_id, name) values (new.id, 'Main Safe')
on conflict do nothing;
return new;
end;
$$;
drop trigger if exists trg_auto_create_safe on app.shops;
create trigger trg_auto_create_safe
after insert on app.shops
for each row execute function app._auto_create_safe();
-- =====================================================================
-- Append-only safe ledger
-- =====================================================================
create table if not exists app.safe_movements (
id uuid primary key default gen_random_uuid(),
safe_id uuid not null references app.safes(id) on delete restrict,
occurred_at timestamptz not null default now(),
-- + cash IN to safe (drop from till), - cash OUT (deposit to bank,
-- expense from safe).
amount numeric(18,2) not null check (amount <> 0),
currency app.currency_code not null,
-- Optional links to the originating events.
ref_shift_id uuid references app.shifts(id),
ref_txn_id uuid references app.transactions(id),
ref_cash_movement_id uuid references app.cash_movements(id),
reason text,
created_by uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now()
);
create index if not exists idx_safe_mov_safe on app.safe_movements(safe_id, occurred_at);
create index if not exists idx_safe_mov_shift on app.safe_movements(ref_shift_id);
-- Append-only.
create or replace function app._safe_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'safe_movements is append-only'; end; $$;
drop trigger if exists trg_safe_mov_freeze on app.safe_movements;
create trigger trg_safe_mov_freeze before update or delete on app.safe_movements
for each row execute function app._safe_mov_no_update_delete();
-- Maintain a per-safe per-currency cached balance.
create table if not exists app.safe_balances (
safe_id uuid not null references app.safes(id) on delete cascade,
currency app.currency_code not null,
balance numeric(20,2) not null default 0,
updated_at timestamptz not null default now(),
primary key (safe_id, currency)
);
create or replace function app._safe_balance_apply()
returns trigger language plpgsql as $$
begin
insert into app.safe_balances(safe_id, currency, balance, updated_at)
values (new.safe_id, new.currency, new.amount, now())
on conflict (safe_id, currency) do update
set balance = app.safe_balances.balance + new.amount,
updated_at = now();
if (select balance from app.safe_balances
where safe_id = new.safe_id and currency = new.currency) < 0 then
raise exception 'safe % would go negative for %', new.safe_id, new.currency;
end if;
return null;
end;
$$;
drop trigger if exists trg_safe_balance_apply on app.safe_movements;
create trigger trg_safe_balance_apply
after insert on app.safe_movements
for each row execute function app._safe_balance_apply();
-- RLS: cashiers can read movements for their shop's safe; managers/owners
-- can write via the SECURITY DEFINER functions below (no direct DML).
alter table app.safe_movements enable row level security;
alter table app.safe_movements force row level security;
alter table app.safe_balances enable row level security;
alter table app.safe_balances force row level security;
drop policy if exists safe_mov_select on app.safe_movements;
create policy safe_mov_select on app.safe_movements
for select using (
exists (
select 1 from app.safes s
where s.id = safe_id
and app.has_any_role_in_shop(s.shop_id,
array['cashier','manager','owner']::app.business_role[])
)
);
drop policy if exists safe_bal_select on app.safe_balances;
create policy safe_bal_select on app.safe_balances
for select using (
exists (
select 1 from app.safes s
where s.id = safe_id
and app.has_any_role_in_shop(s.shop_id,
array['cashier','manager','owner']::app.business_role[])
)
);
-- =====================================================================
-- Bank deposits (paper trail for cash leaving the safe to the bank)
-- =====================================================================
create table if not exists app.bank_deposits (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id),
safe_id uuid not null references app.safes(id),
shift_id uuid references app.shifts(id), -- optional link
amount numeric(18,2) not null check (amount > 0),
currency app.currency_code not null,
bank_ref text,
deposit_slip_url text,
notes text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_bank_dep_shop on app.bank_deposits(shop_id, created_at desc);
-- =====================================================================
-- Re-define record_cash_drop to also credit the safe
-- =====================================================================
drop function if exists app.record_cash_drop(uuid, numeric, numeric, text);
create or replace function app.record_cash_drop(
p_shift_id uuid,
p_drop_usd numeric,
p_drop_lbp numeric,
p_notes text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
s app.shifts%rowtype;
v_safe uuid;
v_cash_id uuid;
begin
if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
raise exception 'drop must be > 0 in at least one currency';
end if;
if coalesce(p_drop_usd,0) < 0 or coalesce(p_drop_lbp,0) < 0 then
raise exception 'drop amounts must be positive (the function negates)';
end if;
select * into s from app.shifts where id = p_shift_id;
if s.id is null then raise exception 'shift not found'; end if;
if s.status <> 'open' then
raise exception 'cannot drop on a closed/declared shift';
end if;
-- Find the shop's main safe.
select id into v_safe from app.safes
where shop_id = s.shop_id and is_active limit 1;
if v_safe is null then
raise exception 'no active safe for shop %', s.shop_id;
end if;
if coalesce(p_drop_usd,0) > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe'::app.cash_movement_type,
'USD'::app.currency_code, -p_drop_usd, p_notes)
returning id into v_cash_id;
insert into app.safe_movements(
safe_id, currency, amount, ref_shift_id, ref_cash_movement_id, reason
) values (
v_safe, 'USD'::app.currency_code, p_drop_usd,
p_shift_id, v_cash_id, coalesce(p_notes, 'till drop')
);
end if;
if coalesce(p_drop_lbp,0) > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe'::app.cash_movement_type,
'LBP'::app.currency_code, -p_drop_lbp, p_notes)
returning id into v_cash_id;
insert into app.safe_movements(
safe_id, currency, amount, ref_shift_id, ref_cash_movement_id, reason
) values (
v_safe, 'LBP'::app.currency_code, p_drop_lbp,
p_shift_id, v_cash_id, coalesce(p_notes, 'till drop')
);
end if;
perform app.log_auth_event('cash_drop', s.shop_id, null,
jsonb_build_object('shift_id', p_shift_id,
'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
-- =====================================================================
-- record_bank_deposit: take cash out of the safe to the bank.
-- Manager-only, leaves a slip-photo URL for evidence.
-- =====================================================================
create or replace function app.record_bank_deposit(
p_shop uuid,
p_currency app.currency_code,
p_amount numeric,
p_bank_ref text,
p_deposit_slip_url text,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_safe uuid;
v_dep uuid;
begin
if p_amount is null or p_amount <= 0 then
raise exception 'deposit amount must be > 0';
end if;
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
select id into v_safe from app.safes
where shop_id = p_shop and is_active limit 1;
if v_safe is null then raise exception 'no active safe for shop'; end if;
insert into app.bank_deposits(
shop_id, safe_id, amount, currency, bank_ref, deposit_slip_url, notes
) values (
p_shop, v_safe, p_amount, p_currency, p_bank_ref, p_deposit_slip_url, p_notes
) returning id into v_dep;
-- Debit the safe (will fail if insufficient balance thanks to the
-- _safe_balance_apply trigger).
insert into app.safe_movements(
safe_id, currency, amount, reason
) values (
v_safe, p_currency, -p_amount,
'bank deposit ' || coalesce(p_bank_ref, v_dep::text)
);
perform app.log_auth_event('bank_deposit', p_shop, null,
jsonb_build_object('deposit_id', v_dep,
'amount', p_amount, 'currency', p_currency,
'bank_ref', p_bank_ref));
return v_dep;
end;
$$;
revoke all on function app.record_bank_deposit(uuid, app.currency_code, numeric, text, text, text) from public;
grant execute on function app.record_bank_deposit(uuid, app.currency_code, numeric, text, text, text) to authenticated;
-- =====================================================================
-- Owner-friendly view of safe balances joined to shop names.
-- =====================================================================
create or replace view app.v_safe_balance as
select s.shop_id,
sh.name as shop_name,
s.id as safe_id,
s.name as safe_name,
b.currency,
coalesce(b.balance, 0) as balance,
b.updated_at
from app.safes s
join app.shops sh on sh.id = s.shop_id
left join app.safe_balances b on b.safe_id = s.id;
grant select on app.v_safe_balance to authenticated;
@@ -0,0 +1,95 @@
-- =====================================================================
-- 0026_manager_seed_rpcs.sql
--
-- The frontend's "supabase" client is actually a thin shim that only
-- supports rpc + select. Manager seeding of fee_schedule and fx_rates
-- therefore needs SECURITY DEFINER wrapper RPCs (also a defense-in-depth
-- improvement over relying on RLS for INSERT).
-- =====================================================================
set search_path = app, public;
create or replace function app.set_fee_bracket(
p_shop uuid,
p_service_code text,
p_currency app.currency_code,
p_min_amount numeric,
p_max_amount numeric,
p_fee_fixed numeric,
p_fee_pct numeric,
p_commission_fixed numeric,
p_commission_pct numeric
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_id uuid;
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if p_min_amount is null or p_max_amount is null
or p_min_amount < 0 or p_max_amount <= p_min_amount then
raise exception 'invalid amount range';
end if;
insert into app.fee_schedule(
shop_id, service_code, currency,
min_amount, max_amount,
fee_fixed, fee_pct, commission_fixed, commission_pct
) values (
p_shop, p_service_code, p_currency,
p_min_amount, p_max_amount,
coalesce(p_fee_fixed,0), coalesce(p_fee_pct,0),
coalesce(p_commission_fixed,0), coalesce(p_commission_pct,0)
) returning id into v_id;
perform app.log_auth_event('fee_bracket_set', p_shop, null,
jsonb_build_object('id', v_id, 'service_code', p_service_code,
'currency', p_currency));
return v_id;
end;
$$;
revoke all on function app.set_fee_bracket(uuid, text, app.currency_code, numeric, numeric, numeric, numeric, numeric, numeric) from public;
grant execute on function app.set_fee_bracket(uuid, text, app.currency_code, numeric, numeric, numeric, numeric, numeric, numeric) to authenticated;
create or replace function app.set_fx_rate(
p_shop uuid,
p_usd_to_lbp numeric,
p_tolerance_pct numeric
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_id uuid;
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if p_usd_to_lbp is null or p_usd_to_lbp <= 0 then
raise exception 'rate must be > 0';
end if;
if coalesce(p_tolerance_pct,0) < 0 or coalesce(p_tolerance_pct,0) > 50 then
raise exception 'tolerance must be between 0 and 50';
end if;
insert into app.fx_rates(shop_id, usd_to_lbp_rate, tolerance_pct)
values (p_shop, p_usd_to_lbp, coalesce(p_tolerance_pct,1.0))
returning id into v_id;
perform app.log_auth_event('fx_rate_set', p_shop, null,
jsonb_build_object('id', v_id, 'rate', p_usd_to_lbp,
'tolerance_pct', p_tolerance_pct));
return v_id;
end;
$$;
revoke all on function app.set_fx_rate(uuid, numeric, numeric) from public;
grant execute on function app.set_fx_rate(uuid, numeric, numeric) to authenticated;
@@ -0,0 +1,113 @@
-- =====================================================================
-- 0027_till_management.sql
--
-- Owner-facing till management:
-- - app.create_till(shop, name) -> uuid
-- - app.rename_till(till, name) -> void
-- - app.set_till_active(till, is_active) -> void
-- - v_manage_tills view (owner sees all, including inactive)
-- =====================================================================
set search_path = app, public;
create or replace function app.create_till(
p_shop uuid,
p_name text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_id uuid;
v_name text := nullif(btrim(p_name), '');
begin
if v_name is null then raise exception 'till name is required'; end if;
if not app.has_role_in_shop(p_shop, 'owner') then
raise exception 'owner role required';
end if;
insert into app.tills(shop_id, name) values (p_shop, v_name)
returning id into v_id;
perform app.log_auth_event('till_created', p_shop, null,
jsonb_build_object('till_id', v_id, 'name', v_name));
return v_id;
exception
when unique_violation then
raise exception 'a till with this name already exists in the shop';
end;
$$;
revoke all on function app.create_till(uuid, text) from public;
grant execute on function app.create_till(uuid, text) to authenticated;
create or replace function app.rename_till(
p_till uuid,
p_name text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_name text := nullif(btrim(p_name), '');
begin
if v_name is null then raise exception 'till name is required'; end if;
select shop_id into v_shop from app.tills where id = p_till;
if v_shop is null then raise exception 'till not found'; end if;
if not app.has_role_in_shop(v_shop, 'owner') then
raise exception 'owner role required';
end if;
update app.tills set name = v_name where id = p_till;
perform app.log_auth_event('till_renamed', v_shop, null,
jsonb_build_object('till_id', p_till, 'name', v_name));
exception
when unique_violation then
raise exception 'a till with this name already exists in the shop';
end;
$$;
revoke all on function app.rename_till(uuid, text) from public;
grant execute on function app.rename_till(uuid, text) to authenticated;
create or replace function app.set_till_active(
p_till uuid,
p_active boolean
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
begin
select shop_id into v_shop from app.tills where id = p_till;
if v_shop is null then raise exception 'till not found'; end if;
if not app.has_role_in_shop(v_shop, 'owner') then
raise exception 'owner role required';
end if;
-- Block deactivating a till that has an open shift on it.
if p_active = false and exists (
select 1 from app.shifts where till_id = p_till and status <> 'closed'
) then
raise exception 'cannot deactivate a till with an open or pending shift';
end if;
update app.tills set is_active = coalesce(p_active, true) where id = p_till;
perform app.log_auth_event(
case when p_active then 'till_activated' else 'till_deactivated' end,
v_shop, null, jsonb_build_object('till_id', p_till));
end;
$$;
revoke all on function app.set_till_active(uuid, boolean) from public;
grant execute on function app.set_till_active(uuid, boolean) to authenticated;
-- Owner view that also includes inactive tills, for the management UI.
drop view if exists app.v_manage_tills;
create view app.v_manage_tills as
select t.id as till_id, t.shop_id, t.name, t.is_active, t.created_at
from app.tills t
where exists (
select 1 from app.user_shop_assignments a
where a.shop_id = t.shop_id
and a.user_id = auth.uid()
and a.role in ('owner', 'manager')
);
grant select on app.v_manage_tills to authenticated;
+94
View File
@@ -0,0 +1,94 @@
-- =====================================================================
-- 0028_live_drawer.sql
--
-- Cashiers and owners need an in-shift view of what the drawer should
-- currently hold before the blind close. The existing close flow already
-- computes this from app.cash_movements; this migration exposes the same
-- math as a read-only RPC for the live UI.
-- =====================================================================
set search_path = app, public;
create or replace function app.live_drawer(p_shift_id uuid)
returns table (
shift_id uuid,
expected_usd numeric,
expected_lbp numeric,
customer_in_usd numeric,
customer_in_lbp numeric,
payout_out_usd numeric,
payout_out_lbp numeric,
dropped_to_safe_usd numeric,
dropped_to_safe_lbp numeric,
fx_net_usd numeric,
fx_net_lbp numeric,
txn_count bigint,
last_txn_at timestamptz
)
language sql
security definer
set search_path = app, public
stable
as $$
with s as (
select sh.id, sh.shop_id, sh.user_id, sh.status
from app.shifts sh
where sh.id = p_shift_id
),
authz as (
select 1
from s
where s.user_id = auth.uid()
or app.has_any_role_in_shop(
s.shop_id,
array['owner','manager','auditor']::app.business_role[]
)
),
cash as (
select
cm.shift_id,
coalesce(sum(cm.amount) filter (where cm.currency = 'USD'), 0) as expected_usd,
coalesce(sum(cm.amount) filter (where cm.currency = 'LBP'), 0) as expected_lbp,
coalesce(sum(cm.amount) filter (where cm.type = 'sale_in' and cm.currency = 'USD'), 0) as customer_in_usd,
coalesce(sum(cm.amount) filter (where cm.type = 'sale_in' and cm.currency = 'LBP'), 0) as customer_in_lbp,
coalesce(sum(-cm.amount) filter (where cm.type = 'payout_out' and cm.currency = 'USD'), 0) as payout_out_usd,
coalesce(sum(-cm.amount) filter (where cm.type = 'payout_out' and cm.currency = 'LBP'), 0) as payout_out_lbp,
coalesce(sum(-cm.amount) filter (where cm.type = 'drop_to_safe' and cm.currency = 'USD'), 0) as dropped_to_safe_usd,
coalesce(sum(-cm.amount) filter (where cm.type = 'drop_to_safe' and cm.currency = 'LBP'), 0) as dropped_to_safe_lbp,
coalesce(sum(cm.amount) filter (where cm.type in ('fx_swap_in', 'fx_swap_out') and cm.currency = 'USD'), 0) as fx_net_usd,
coalesce(sum(cm.amount) filter (where cm.type in ('fx_swap_in', 'fx_swap_out') and cm.currency = 'LBP'), 0) as fx_net_lbp
from app.cash_movements cm
where cm.shift_id = p_shift_id
group by cm.shift_id
),
tx as (
select
t.shift_id,
count(*) filter (where t.status = 'completed') as txn_count,
max(t.occurred_at) filter (where t.status = 'completed') as last_txn_at
from app.transactions t
where t.shift_id = p_shift_id
group by t.shift_id
)
select
s.id as shift_id,
coalesce(cash.expected_usd, 0) as expected_usd,
coalesce(cash.expected_lbp, 0) as expected_lbp,
coalesce(cash.customer_in_usd, 0) as customer_in_usd,
coalesce(cash.customer_in_lbp, 0) as customer_in_lbp,
coalesce(cash.payout_out_usd, 0) as payout_out_usd,
coalesce(cash.payout_out_lbp, 0) as payout_out_lbp,
coalesce(cash.dropped_to_safe_usd, 0) as dropped_to_safe_usd,
coalesce(cash.dropped_to_safe_lbp, 0) as dropped_to_safe_lbp,
coalesce(cash.fx_net_usd, 0) as fx_net_usd,
coalesce(cash.fx_net_lbp, 0) as fx_net_lbp,
coalesce(tx.txn_count, 0) as txn_count,
tx.last_txn_at
from s
join authz on true
left join cash on cash.shift_id = s.id
left join tx on tx.shift_id = s.id;
$$;
revoke all on function app.live_drawer(uuid) from public;
grant execute on function app.live_drawer(uuid) to authenticated;
@@ -0,0 +1,48 @@
-- =====================================================================
-- 0029_my_active_shift.sql
--
-- Shift Control needs visibility into both OPEN and DECLARED shifts so a
-- cashier can declare, review the drawer, and still finalize after a
-- refresh. Transaction entry keeps using my_open_shift so no sales can be
-- posted once close has been declared.
-- =====================================================================
set search_path = app, public;
create or replace function app.my_active_shift(p_shop uuid)
returns table (
shift_id uuid,
till_id uuid,
opened_at timestamptz,
status app.shift_status,
opening_usd numeric,
opening_lbp numeric,
declared_at timestamptz,
declared_close_usd numeric,
declared_close_lbp numeric
)
language sql
security definer
set search_path = app, public
stable
as $$
select
id,
till_id,
opened_at,
status,
opening_usd,
opening_lbp,
declared_at,
declared_close_usd,
declared_close_lbp
from app.shifts
where shop_id = p_shop
and user_id = auth.uid()
and status <> 'closed'
order by opened_at desc
limit 1;
$$;
revoke all on function app.my_active_shift(uuid) from public;
grant execute on function app.my_active_shift(uuid) to authenticated;
@@ -0,0 +1,3 @@
set search_path = app, public;
grant select on app.v_owner_dashboard to authenticated;
@@ -0,0 +1,309 @@
set search_path = app, public;
create table if not exists app.end_of_day_reports (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
business_date date not null,
submitted_at timestamptz not null default now(),
submitted_by uuid not null references auth.users(id) on delete restrict,
submitted_by_name text not null,
note text,
period_started_at timestamptz not null,
period_ended_at timestamptz not null,
completed_txn_count bigint not null default 0,
voided_txn_count bigint not null default 0,
gross_usd numeric(14,2) not null default 0,
gross_lbp numeric(18,0) not null default 0,
safe_drop_usd numeric(14,2) not null default 0,
safe_drop_lbp numeric(18,0) not null default 0,
closed_shift_count bigint not null default 0,
total_variance_usd numeric(14,2) not null default 0,
total_variance_lbp numeric(18,0) not null default 0,
activity_count bigint not null default 0,
activity_log jsonb not null default '[]'::jsonb,
unique (shop_id, business_date)
);
alter table app.end_of_day_reports enable row level security;
alter table app.end_of_day_reports force row level security;
revoke insert, update, delete on app.end_of_day_reports from authenticated;
drop policy if exists end_of_day_reports_select on app.end_of_day_reports;
create policy end_of_day_reports_select on app.end_of_day_reports
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','auditor']::app.business_role[])
);
grant select on app.end_of_day_reports to authenticated;
create or replace view app.v_end_of_day_reports as
select
id,
shop_id,
business_date,
submitted_at,
submitted_by,
submitted_by_name,
note,
period_started_at,
period_ended_at,
completed_txn_count,
voided_txn_count,
gross_usd,
gross_lbp,
safe_drop_usd,
safe_drop_lbp,
closed_shift_count,
total_variance_usd,
total_variance_lbp,
activity_count,
activity_log
from app.end_of_day_reports;
grant select on app.v_end_of_day_reports to authenticated;
create or replace function app.submit_end_of_day(
p_shop uuid,
p_note text default null
)
returns table (
report_id uuid,
business_date date,
submitted_at timestamptz,
completed_txn_count bigint,
gross_usd numeric,
gross_lbp numeric,
activity_count bigint
)
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_period_started_at timestamptz;
v_period_ended_at timestamptz := now();
v_business_date date := (now() at time zone 'Asia/Beirut')::date;
v_open_shift_count integer;
v_report_id uuid;
v_submitted_by_name text;
v_completed_txn_count bigint;
v_voided_txn_count bigint;
v_gross_usd numeric(14,2);
v_gross_lbp numeric(18,0);
v_safe_drop_usd numeric(14,2);
v_safe_drop_lbp numeric(18,0);
v_closed_shift_count bigint;
v_total_variance_usd numeric(14,2);
v_total_variance_lbp numeric(18,0);
v_activity_count bigint;
v_activity_log jsonb;
begin
if not app.has_role_in_shop(p_shop, 'owner') then
raise exception 'owner role required';
end if;
select count(*)
into v_open_shift_count
from app.shifts
where shop_id = p_shop
and status <> 'closed';
if v_open_shift_count > 0 then
raise exception 'close or finalize all shifts before submitting end of day';
end if;
if exists (
select 1
from app.end_of_day_reports eod
where eod.shop_id = p_shop
and eod.business_date = v_business_date
) then
raise exception 'end of day already submitted for this shop today';
end if;
select coalesce(
max(period_ended_at),
date_trunc('day', now() at time zone 'Asia/Beirut') at time zone 'Asia/Beirut'
)
into v_period_started_at
from app.end_of_day_reports
where shop_id = p_shop;
select coalesce(full_name, email, auth.uid()::text)
into v_submitted_by_name
from auth.users
where id = auth.uid();
select
count(*) filter (where t.status = 'completed'),
count(*) filter (where t.status = 'voided'),
coalesce(sum(t.gross_usd) filter (where t.status = 'completed'), 0),
coalesce(sum(t.gross_lbp) filter (where t.status = 'completed'), 0)
into v_completed_txn_count, v_voided_txn_count, v_gross_usd, v_gross_lbp
from app.transactions t
where t.shop_id = p_shop
and t.occurred_at >= v_period_started_at
and t.occurred_at <= v_period_ended_at;
select
coalesce(sum(case when cm.currency = 'USD' then abs(cm.amount) else 0 end), 0),
coalesce(sum(case when cm.currency = 'LBP' then abs(cm.amount) else 0 end), 0)
into v_safe_drop_usd, v_safe_drop_lbp
from app.cash_movements cm
join app.shifts sh on sh.id = cm.shift_id
where sh.shop_id = p_shop
and cm.type = 'drop_to_safe'
and cm.occurred_at >= v_period_started_at
and cm.occurred_at <= v_period_ended_at;
select
count(*),
coalesce(sum(sh.variance_usd), 0),
coalesce(sum(sh.variance_lbp), 0)
into v_closed_shift_count, v_total_variance_usd, v_total_variance_lbp
from app.shifts sh
where sh.shop_id = p_shop
and sh.status = 'closed'
and sh.closed_at is not null
and sh.closed_at >= v_period_started_at
and sh.closed_at <= v_period_ended_at;
select
count(*),
coalesce(
jsonb_agg(
jsonb_build_object(
'occurred_at', ae.occurred_at,
'event_type', ae.event_type,
'metadata', ae.metadata
)
order by ae.occurred_at desc
),
'[]'::jsonb
)
into v_activity_count, v_activity_log
from app.auth_events ae
where ae.shop_id = p_shop
and ae.occurred_at >= v_period_started_at
and ae.occurred_at <= v_period_ended_at
and ae.event_type <> 'end_of_day_submitted';
insert into app.end_of_day_reports(
shop_id,
business_date,
submitted_by,
submitted_by_name,
note,
period_started_at,
period_ended_at,
completed_txn_count,
voided_txn_count,
gross_usd,
gross_lbp,
safe_drop_usd,
safe_drop_lbp,
closed_shift_count,
total_variance_usd,
total_variance_lbp,
activity_count,
activity_log
)
values (
p_shop,
v_business_date,
auth.uid(),
coalesce(v_submitted_by_name, auth.uid()::text),
nullif(btrim(coalesce(p_note, '')), ''),
v_period_started_at,
v_period_ended_at,
coalesce(v_completed_txn_count, 0),
coalesce(v_voided_txn_count, 0),
coalesce(v_gross_usd, 0),
coalesce(v_gross_lbp, 0),
coalesce(v_safe_drop_usd, 0),
coalesce(v_safe_drop_lbp, 0),
coalesce(v_closed_shift_count, 0),
coalesce(v_total_variance_usd, 0),
coalesce(v_total_variance_lbp, 0),
coalesce(v_activity_count, 0),
coalesce(v_activity_log, '[]'::jsonb)
)
returning id into v_report_id;
perform app.log_auth_event(
'end_of_day_submitted',
p_shop,
null,
jsonb_build_object(
'report_id', v_report_id,
'business_date', v_business_date,
'completed_txn_count', coalesce(v_completed_txn_count, 0),
'gross_usd', coalesce(v_gross_usd, 0),
'gross_lbp', coalesce(v_gross_lbp, 0),
'activity_count', coalesce(v_activity_count, 0)
)
);
return query
select
r.id,
r.business_date,
r.submitted_at,
r.completed_txn_count,
r.gross_usd,
r.gross_lbp,
r.activity_count
from app.end_of_day_reports r
where r.id = v_report_id;
end;
$$;
revoke all on function app.submit_end_of_day(uuid, text) from public;
grant execute on function app.submit_end_of_day(uuid, text) to authenticated;
create or replace view app.v_owner_dashboard as
with latest_eod as (
select distinct on (shop_id)
shop_id,
submitted_at
from app.end_of_day_reports
order by shop_id, submitted_at desc
),
periods as (
select
s.id as shop_id,
coalesce(
le.submitted_at,
date_trunc('day', now() at time zone 'Asia/Beirut') at time zone 'Asia/Beirut'
) as period_started_at,
le.submitted_at as last_end_of_day_at
from app.shops s
left join latest_eod le on le.shop_id = s.id
)
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(t.gross_usd), 0)
from app.transactions t
where t.shop_id = s.id
and t.status = 'completed'
and t.occurred_at >= p.period_started_at) as today_gross_usd,
(select coalesce(sum(t.gross_lbp), 0)
from app.transactions t
where t.shop_id = s.id
and t.status = 'completed'
and t.occurred_at >= p.period_started_at) as today_gross_lbp,
p.period_started_at,
p.last_end_of_day_at
from app.shops s
join periods p on p.shop_id = s.id;
@@ -0,0 +1,71 @@
set search_path = app, public;
create or replace function app.open_shift(
p_till_id uuid,
p_opening_usd numeric,
p_opening_lbp numeric,
p_assigned_user_id uuid default null
)
returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_shift uuid;
v_target_user uuid;
begin
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
if v_shop is null then
raise exception 'till % not found or inactive', p_till_id;
end if;
if p_assigned_user_id is null then
if not app.has_any_role_in_shop(v_shop, array['cashier','manager']::app.business_role[]) then
raise exception 'not authorized to open a shift on this till';
end if;
v_target_user := auth.uid();
else
if not app.has_any_role_in_shop(v_shop, array['owner','manager']::app.business_role[]) then
raise exception 'only managers or owners can assign shifts to other users';
end if;
v_target_user := p_assigned_user_id;
if not exists (
select 1 from app.user_shop_assignments
where user_id = v_target_user and shop_id = v_shop
) then
raise exception 'target user does not have a role in this shop';
end if;
end if;
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
raise exception 'till % already has an active shift; close it first', p_till_id;
end if;
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
values (p_till_id, v_shop, v_target_user, auth.uid(), p_opening_usd, p_opening_lbp)
returning id into v_shift;
insert into app.cash_movements(shift_id, type, currency, amount, note)
select v_shift, 'opening_float', x.currency, x.amount, 'opening float'
from (values ('USD'::app.currency_code, p_opening_usd), ('LBP'::app.currency_code, p_opening_lbp))
as x(currency, amount)
where x.amount > 0;
perform app.log_auth_event(
'shift_opened',
v_shop,
null,
jsonb_build_object(
'shift_id', v_shift,
'till_id', p_till_id,
'assigned_user_id', v_target_user
)
);
return v_shift;
end;
$$;
revoke all on function app.open_shift(uuid, numeric, numeric, uuid) from public;
grant execute on function app.open_shift(uuid, numeric, numeric, uuid) to authenticated;
@@ -0,0 +1,4 @@
set search_path = app, public;
alter type app.alert_kind add value if not exists 'single_short';
alter type app.alert_kind add value if not exists 'suspicious_safe_drop';
@@ -0,0 +1,267 @@
set search_path = app, public;
create or replace view app.v_alert_single_short as
select
z.shift_id,
z.shop_id,
z.cashier_id,
z.closed_at,
coalesce(z.expected_close_usd, 0) as expected_close_usd,
coalesce(z.expected_close_lbp, 0) as expected_close_lbp,
coalesce(z.declared_close_usd, 0) as declared_close_usd,
coalesce(z.declared_close_lbp, 0) as declared_close_lbp,
coalesce(z.variance_usd, 0) as variance_usd,
coalesce(z.variance_lbp, 0) as variance_lbp
from app.v_z_report z
where z.status = 'closed'
and (
coalesce(z.variance_usd, 0) < 0
or coalesce(z.variance_lbp, 0) < 0
);
create or replace view app.v_alert_suspicious_safe_drops as
with drop_stats as (
select
sh.id as shift_id,
sh.shop_id,
sh.user_id as cashier_id,
sh.status,
sh.opened_at,
sh.declared_at,
sh.closed_at,
count(*) filter (where cm.type = 'drop_to_safe') as drop_count,
coalesce(sum(abs(cm.amount)) filter (where cm.type = 'drop_to_safe' and cm.currency = 'USD'), 0) as dropped_usd,
coalesce(sum(abs(cm.amount)) filter (where cm.type = 'drop_to_safe' and cm.currency = 'LBP'), 0) as dropped_lbp,
max(cm.occurred_at) filter (where cm.type = 'drop_to_safe') as last_drop_at
from app.shifts sh
join app.cash_movements cm on cm.shift_id = sh.id
group by sh.id, sh.shop_id, sh.user_id, sh.status, sh.opened_at, sh.declared_at, sh.closed_at
)
select
ds.shift_id,
ds.shop_id,
ds.cashier_id,
ds.status,
ds.opened_at,
ds.declared_at,
ds.closed_at,
ds.drop_count,
ds.dropped_usd,
ds.dropped_lbp,
ds.last_drop_at,
coalesce(sh.variance_usd, 0) as variance_usd,
coalesce(sh.variance_lbp, 0) as variance_lbp,
(ds.drop_count >= 3) as repeated_drops,
(
coalesce(ds.declared_at, ds.closed_at) is not null
and ds.last_drop_at >= coalesce(ds.declared_at, ds.closed_at) - interval '15 minutes'
) as near_close_drop,
(
(coalesce(sh.variance_usd, 0) < 0 or coalesce(sh.variance_lbp, 0) < 0)
and (coalesce(ds.dropped_usd, 0) > 0 or coalesce(ds.dropped_lbp, 0) > 0)
) as short_after_drop
from drop_stats ds
join app.shifts sh on sh.id = ds.shift_id
where ds.drop_count >= 3
or (
coalesce(ds.declared_at, ds.closed_at) is not null
and ds.last_drop_at >= coalesce(ds.declared_at, ds.closed_at) - interval '15 minutes'
)
or (
(coalesce(sh.variance_usd, 0) < 0 or coalesce(sh.variance_lbp, 0) < 0)
and (coalesce(ds.dropped_usd, 0) > 0 or coalesce(ds.dropped_lbp, 0) > 0)
);
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
-- Immediate short shift: any negative close variance.
for r in select * from app.v_alert_single_short loop
if app._raise_alert(r.shop_id, 'single_short', 'warn',
r.shift_id,
jsonb_build_object(
'cashier_id', r.cashier_id,
'closed_at', r.closed_at,
'expected_close_usd', r.expected_close_usd,
'expected_close_lbp', r.expected_close_lbp,
'declared_close_usd', r.declared_close_usd,
'declared_close_lbp', r.declared_close_lbp,
'variance_usd', r.variance_usd,
'variance_lbp', r.variance_lbp
),
format('single_short:%s', r.shift_id)
) is not null then n := n + 1; end if;
end loop;
-- 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;
-- Suspicious safe-drop patterns: repeated drops, late drops, or a short close after drops.
for r in select * from app.v_alert_suspicious_safe_drops loop
if app._raise_alert(r.shop_id, 'suspicious_safe_drop', 'warn',
r.shift_id,
jsonb_build_object(
'cashier_id', r.cashier_id,
'status', r.status,
'opened_at', r.opened_at,
'declared_at', r.declared_at,
'closed_at', r.closed_at,
'drop_count', r.drop_count,
'dropped_usd', r.dropped_usd,
'dropped_lbp', r.dropped_lbp,
'last_drop_at', r.last_drop_at,
'variance_usd', r.variance_usd,
'variance_lbp', r.variance_lbp,
'repeated_drops', r.repeated_drops,
'near_close_drop', r.near_close_drop,
'short_after_drop', r.short_after_drop
),
format('suspicious_safe_drop:%s', r.shift_id)
) 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
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,
'sum_usd', r.sum_usd,
'sum_lbp', r.sum_lbp),
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, user_id as 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('gap_starts_at', r.gap_starts_at, 'gap_ends_at', r.gap_ends_at),
format('reference_gap:%s:%s:%s', r.shop_id, r.gap_starts_at, r.gap_ends_at)
) 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;
@@ -0,0 +1,15 @@
set search_path = app, public;
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;
@@ -0,0 +1,36 @@
set search_path = app, public;
create or replace function app.verify_chain(p_shop uuid)
returns table (txn_id uuid, reference_no bigint, ok boolean)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare prev bytea;
rec app.transactions%rowtype;
begin
if auth.uid() is not null
and not app.has_role_in_shop(p_shop, 'owner')
and not app.has_role_in_shop(p_shop, 'auditor') then
raise exception 'not authorized';
end if;
prev := null;
for rec in
select * from app.transactions
where shop_id = p_shop
order by reference_no
loop
txn_id := rec.id;
reference_no := rec.reference_no;
ok := (rec.prev_row_hash is not distinct from prev)
and (rec.row_hash = app.txn_compute_hash(rec, prev));
prev := rec.row_hash;
return next;
end loop;
end;
$$;
revoke all on function app.verify_chain(uuid) from public;
grant execute on function app.verify_chain(uuid) to authenticated;
+44
View File
@@ -0,0 +1,44 @@
# Supabase migrations
Numbered, append-only SQL migrations. Each file matches a step from
`docs/THREAT_MODEL.md` and the implementation roadmap. Never edit a
migration after it ships — add a new one.
| File | Roadmap step | Summary |
| ----------------------------------- | ------------ | -------------------------------------------------- |
| `0001_auth_and_org.sql` | 1, 2 | Schema, roles, shops, tills, profiles, PIN, RLS. |
| `0002_shifts_and_cash.sql` | 3 | Shifts, blind close, append-only cash movements. |
| `0003_transactions_ledger.sql` | 4 | Append-only ledger, hash chain, voids, services. |
| `0004_service_details.sql` | 5 | OMT/recharge/bill/goods/repair detail tables. |
| `0005_inventory_and_float.sql` | 6 | Items, stock lots, vouchers, e-float ledger. |
| `0006_customers_and_kyc.sql` | 7 | Customers, KYC docs, AML thresholds & detection. |
| `0007_receipts_and_evidence.sql` | 8 | HMAC-signed receipts, notifications, evidence. |
| `0008_refunds_and_overrides.sql` | 9 | Refunds, price overrides, collusion views. |
| `0009_external_reconciliation.sql` | 10 | Provider statements, matching, exceptions, close. |
| `0010_reporting_and_alerts.sql` | 11 | Z-report, P&L, scorecards, alert detectors. |
| `0011_hardening.sql` | 12 | pg_cron, daily anchor, key rotation, DDL lock. |
| `0013_user_shift_record_rpcs.sql` | 13a/b | `app.me`, `my_open_shift`, `record_*` per-service RPCs, fixed Z-report / scorecard / AML views, service catalog seed. |
## Applying
With the Supabase CLI:
```sh
supabase db push
```
Or manually against a Postgres instance:
```sh
psql "$DATABASE_URL" -f supabase/migrations/0001_auth_and_org.sql
```
## Conventions
- All app tables live in the `app` schema; `public` stays empty for client SDK
type generation comfort. (We can later expose read-only views in `public`.)
- Every table has RLS **enabled and forced** from the migration that creates
it. No table is ever public-readable.
- All money-changing operations go through `SECURITY DEFINER` functions,
not raw table writes. Migrations 0002+ will add them.
- `DELETE` is revoked on audit/ledger tables from every role.