Files
OMT-SM/supabase/migrations/0007_receipts_and_evidence.sql
T

520 lines
20 KiB
PL/PgSQL

-- =====================================================================
-- 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.<txn_id>.<reference_no>.<sig_b64>
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 ----------------------------------------------------