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