Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
-- =====================================================================
|
||||
-- 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 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user