Files
OMT-SM/supabase/migrations/0003_transactions_ledger.sql
T

526 lines
20 KiB
PL/PgSQL

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