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