526 lines
20 KiB
PL/PgSQL
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 ----------------------------------------------------
|