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