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