Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
-- =====================================================================
|
||||
-- Migration 0004 — Service-specific detail tables (roadmap Step 5).
|
||||
--
|
||||
-- Each child row is 1-to-1 with a row in app.transactions and is
|
||||
-- mandatory for its service. A check trigger blocks completing a txn of
|
||||
-- a given service without the matching detail row.
|
||||
--
|
||||
-- Threat-model rows addressed: 1, 3, 5, 16, 19, 21.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Common helpers
|
||||
-- ---------------------------------------------------------------------
|
||||
do $$ begin
|
||||
create type app.id_doc_type as enum (
|
||||
'lebanese_id', 'passport', 'residence_permit', 'driver_license', 'other'
|
||||
);
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
do $$ begin
|
||||
create type app.transfer_direction as enum ('domestic', 'international');
|
||||
exception when duplicate_object then null; end $$;
|
||||
|
||||
-- A small helper used by all child triggers: the parent txn must exist,
|
||||
-- be 'completed' (we attach detail at insert time only), and match the
|
||||
-- expected service_code.
|
||||
create or replace function app._require_txn_service(p_txn uuid, p_service text)
|
||||
returns void
|
||||
language plpgsql
|
||||
stable
|
||||
as $$
|
||||
declare svc text; st app.txn_status;
|
||||
begin
|
||||
select service_code, status into svc, st
|
||||
from app.transactions where id = p_txn;
|
||||
if svc is null then raise exception 'transaction % not found', p_txn; end if;
|
||||
if svc <> p_service then
|
||||
raise exception 'detail mismatch: txn service is % but detail row is for %',
|
||||
svc, p_service;
|
||||
end if;
|
||||
if st <> 'completed' then
|
||||
raise exception 'cannot attach detail to a % transaction', st;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =====================================================================
|
||||
-- OMT — Send
|
||||
-- =====================================================================
|
||||
create table if not exists app.omt_send_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
direction app.transfer_direction not null,
|
||||
|
||||
sender_full_name text not null,
|
||||
sender_id_type app.id_doc_type not null,
|
||||
sender_id_number text not null,
|
||||
sender_phone text not null,
|
||||
sender_dob date,
|
||||
sender_nationality text,
|
||||
|
||||
beneficiary_full_name text not null,
|
||||
beneficiary_phone text,
|
||||
destination_country text, -- ISO-3166 alpha-2 expected for international
|
||||
|
||||
purpose_code text not null, -- 'family_support','salary','goods','services',...
|
||||
purpose_note text,
|
||||
kyc_doc_url text, -- ID photo / declaration
|
||||
|
||||
created_at timestamptz not null default now(),
|
||||
constraint omt_send_intl_country_required check (
|
||||
direction = 'domestic' or destination_country is not null
|
||||
),
|
||||
constraint omt_send_id_format check (length(btrim(sender_id_number)) >= 4)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- OMT — Receive / Payout
|
||||
-- =====================================================================
|
||||
create table if not exists app.omt_receive_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
payout_code text not null, -- the customer-presented code
|
||||
|
||||
beneficiary_full_name text not null,
|
||||
beneficiary_id_type app.id_doc_type not null,
|
||||
beneficiary_id_number text not null,
|
||||
beneficiary_phone text,
|
||||
|
||||
origin_country text,
|
||||
kyc_doc_url text,
|
||||
|
||||
created_at timestamptz not null default now(),
|
||||
constraint omt_recv_id_format check (length(btrim(beneficiary_id_number)) >= 4),
|
||||
constraint omt_recv_code_format check (length(btrim(payout_code)) >= 6)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Bill payment (EDL, water, internet bills, gov fees, etc.)
|
||||
-- =====================================================================
|
||||
create table if not exists app.bill_payment_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
biller_code text not null, -- 'EDL','OGERO','MOF','NSSF',...
|
||||
account_number text not null,
|
||||
period text, -- '2026-04', invoice id, etc.
|
||||
customer_name text,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint bill_account_format check (length(btrim(account_number)) >= 3)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Recharge (Alfa / touch / Ogero / Internet)
|
||||
-- Either a voucher_serial (physical scratch card) OR an
|
||||
-- e_recharge_provider_ref (provider confirmation id) is mandatory.
|
||||
-- =====================================================================
|
||||
create table if not exists app.recharge_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
operator text not null, -- 'ALFA','TOUCH','OGERO','IDM','CYBERIA','TERRANET'
|
||||
msisdn text not null, -- subscriber number being recharged
|
||||
product_code text not null, -- 'U-CARD-22USD','MAGIC-11USD','DATA-5GB',...
|
||||
voucher_serial text, -- if scratch card
|
||||
e_recharge_provider_ref text, -- if e-recharge
|
||||
unit_face_value_usd numeric(14,2),
|
||||
unit_cost_usd numeric(14,2), -- cost to shop (margin = price - cost)
|
||||
created_at timestamptz not null default now(),
|
||||
constraint recharge_msisdn_format check (msisdn ~ '^\+?\d{6,15}$'),
|
||||
constraint recharge_must_have_evidence check (
|
||||
(voucher_serial is not null) or (e_recharge_provider_ref is not null)
|
||||
)
|
||||
);
|
||||
|
||||
-- A given voucher serial may only ever be sold once across the whole
|
||||
-- system (vector #4: skim a card, claim "lost").
|
||||
create unique index if not exists uq_recharge_voucher_serial
|
||||
on app.recharge_details(voucher_serial)
|
||||
where voucher_serial is not null;
|
||||
|
||||
-- An e-recharge provider reference is unique per operator.
|
||||
create unique index if not exists uq_recharge_provider_ref
|
||||
on app.recharge_details(operator, e_recharge_provider_ref)
|
||||
where e_recharge_provider_ref is not null;
|
||||
|
||||
create index if not exists idx_recharge_msisdn on app.recharge_details(msisdn);
|
||||
create index if not exists idx_recharge_operator on app.recharge_details(operator);
|
||||
|
||||
-- =====================================================================
|
||||
-- Goods sale (SIM / phone / accessory) and repair
|
||||
-- Real inventory FK comes in 0006; for now we capture sku + qty.
|
||||
-- =====================================================================
|
||||
create table if not exists app.goods_sale_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
sku text not null,
|
||||
qty integer not null check (qty > 0),
|
||||
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
|
||||
unit_price_usd numeric(14,2) not null check (unit_price_usd >= 0),
|
||||
serial_number text, -- IMEI for phones
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists app.repair_details (
|
||||
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||
device_type text not null,
|
||||
device_imei text,
|
||||
issue_summary text not null,
|
||||
warranty_days integer not null default 0 check (warranty_days >= 0),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Triggers — service consistency + append-only on details
|
||||
-- =====================================================================
|
||||
create or replace function app._detail_no_update_delete()
|
||||
returns trigger language plpgsql as $$
|
||||
begin raise exception 'transaction detail rows are append-only'; end;
|
||||
$$;
|
||||
|
||||
-- per-table: stamp + service-code check + immutability
|
||||
create or replace function app.omt_send_check()
|
||||
returns trigger language plpgsql as $$
|
||||
begin perform app._require_txn_service(new.txn_id, 'OMT_SEND'); return new; end;
|
||||
$$;
|
||||
drop trigger if exists trg_omt_send_check on app.omt_send_details;
|
||||
create trigger trg_omt_send_check before insert on app.omt_send_details
|
||||
for each row execute function app.omt_send_check();
|
||||
drop trigger if exists trg_omt_send_freeze on app.omt_send_details;
|
||||
create trigger trg_omt_send_freeze before update or delete on app.omt_send_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
create or replace function app.omt_recv_check()
|
||||
returns trigger language plpgsql as $$
|
||||
begin perform app._require_txn_service(new.txn_id, 'OMT_RECEIVE'); return new; end;
|
||||
$$;
|
||||
drop trigger if exists trg_omt_recv_check on app.omt_receive_details;
|
||||
create trigger trg_omt_recv_check before insert on app.omt_receive_details
|
||||
for each row execute function app.omt_recv_check();
|
||||
drop trigger if exists trg_omt_recv_freeze on app.omt_receive_details;
|
||||
create trigger trg_omt_recv_freeze before update or delete on app.omt_receive_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
create or replace function app.bill_pay_check()
|
||||
returns trigger language plpgsql as $$
|
||||
begin perform app._require_txn_service(new.txn_id, 'OMT_BILL'); return new; end;
|
||||
$$;
|
||||
drop trigger if exists trg_bill_pay_check on app.bill_payment_details;
|
||||
create trigger trg_bill_pay_check before insert on app.bill_payment_details
|
||||
for each row execute function app.bill_pay_check();
|
||||
drop trigger if exists trg_bill_pay_freeze on app.bill_payment_details;
|
||||
create trigger trg_bill_pay_freeze before update or delete on app.bill_payment_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
-- Recharge: any of the four recharge service codes is acceptable.
|
||||
create or replace function app.recharge_check()
|
||||
returns trigger language plpgsql as $$
|
||||
declare svc text;
|
||||
begin
|
||||
select service_code into svc from app.transactions where id = new.txn_id;
|
||||
if svc not in ('ALFA_RECHARGE','TOUCH_RECHARGE','OGERO_RECHARGE','INTERNET_RECHARGE') then
|
||||
raise exception 'recharge_details only valid for recharge services (got %)', svc;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
drop trigger if exists trg_recharge_check on app.recharge_details;
|
||||
create trigger trg_recharge_check before insert on app.recharge_details
|
||||
for each row execute function app.recharge_check();
|
||||
drop trigger if exists trg_recharge_freeze on app.recharge_details;
|
||||
create trigger trg_recharge_freeze before update or delete on app.recharge_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
create or replace function app.goods_sale_check()
|
||||
returns trigger language plpgsql as $$
|
||||
declare svc text;
|
||||
begin
|
||||
select service_code into svc from app.transactions where id = new.txn_id;
|
||||
if svc not in ('SIM_SALE','PHONE_SALE','ACCESSORY_SALE') then
|
||||
raise exception 'goods_sale_details only valid for goods services (got %)', svc;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
drop trigger if exists trg_goods_sale_check on app.goods_sale_details;
|
||||
create trigger trg_goods_sale_check before insert on app.goods_sale_details
|
||||
for each row execute function app.goods_sale_check();
|
||||
drop trigger if exists trg_goods_sale_freeze on app.goods_sale_details;
|
||||
create trigger trg_goods_sale_freeze before update or delete on app.goods_sale_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
create or replace function app.repair_check()
|
||||
returns trigger language plpgsql as $$
|
||||
begin perform app._require_txn_service(new.txn_id, 'REPAIR'); return new; end;
|
||||
$$;
|
||||
drop trigger if exists trg_repair_check on app.repair_details;
|
||||
create trigger trg_repair_check before insert on app.repair_details
|
||||
for each row execute function app.repair_check();
|
||||
drop trigger if exists trg_repair_freeze on app.repair_details;
|
||||
create trigger trg_repair_freeze before update or delete on app.repair_details
|
||||
for each row execute function app._detail_no_update_delete();
|
||||
|
||||
-- =====================================================================
|
||||
-- Cross-row check: a completed transaction must have its matching
|
||||
-- detail row. Implemented as a deferred constraint trigger that fires
|
||||
-- at COMMIT time on app.transactions, so client code can do
|
||||
-- BEGIN; INSERT txn; INSERT detail; COMMIT;
|
||||
-- =====================================================================
|
||||
create or replace function app.txn_require_detail()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare ok boolean;
|
||||
begin
|
||||
if new.status <> 'completed' then return null; end if;
|
||||
case new.service_code
|
||||
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
|
||||
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
|
||||
else ok := true; -- unknown / future services: allow until a child is added
|
||||
end case;
|
||||
if not ok then
|
||||
raise exception 'transaction % (service %) is missing its detail row',
|
||||
new.id, new.service_code;
|
||||
end if;
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_txn_require_detail on app.transactions;
|
||||
create constraint trigger trg_txn_require_detail
|
||||
after insert on app.transactions
|
||||
deferrable initially deferred
|
||||
for each row execute function app.txn_require_detail();
|
||||
|
||||
-- =====================================================================
|
||||
-- RLS — visibility follows the parent transaction.
|
||||
-- =====================================================================
|
||||
alter table app.omt_send_details enable row level security;
|
||||
alter table app.omt_receive_details enable row level security;
|
||||
alter table app.bill_payment_details enable row level security;
|
||||
alter table app.recharge_details enable row level security;
|
||||
alter table app.goods_sale_details enable row level security;
|
||||
alter table app.repair_details enable row level security;
|
||||
|
||||
alter table app.omt_send_details force row level security;
|
||||
alter table app.omt_receive_details force row level security;
|
||||
alter table app.bill_payment_details force row level security;
|
||||
alter table app.recharge_details force row level security;
|
||||
alter table app.goods_sale_details force row level security;
|
||||
alter table app.repair_details force row level security;
|
||||
|
||||
revoke update, delete on
|
||||
app.omt_send_details, app.omt_receive_details, app.bill_payment_details,
|
||||
app.recharge_details, app.goods_sale_details, app.repair_details
|
||||
from authenticated;
|
||||
|
||||
-- Helper: visibility predicate based on parent txn.
|
||||
create or replace function app._can_see_txn(p_txn uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from app.transactions t
|
||||
where t.id = p_txn
|
||||
and (
|
||||
t.user_id = auth.uid()
|
||||
or app.has_any_role_in_shop(t.shop_id,
|
||||
array['owner','manager','auditor']::app.business_role[])
|
||||
)
|
||||
);
|
||||
$$;
|
||||
revoke all on function app._can_see_txn(uuid) from public;
|
||||
grant execute on function app._can_see_txn(uuid) to authenticated;
|
||||
|
||||
-- Insert allowed if the user owns the parent txn's open shift.
|
||||
create or replace function app._can_write_detail(p_txn uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = app, public
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from app.transactions t
|
||||
join app.shifts s on s.id = t.shift_id
|
||||
where t.id = p_txn
|
||||
and t.user_id = auth.uid()
|
||||
and s.status = 'open'
|
||||
);
|
||||
$$;
|
||||
revoke all on function app._can_write_detail(uuid) from public;
|
||||
grant execute on function app._can_write_detail(uuid) to authenticated;
|
||||
|
||||
-- Apply identical select/insert policies to all six child tables.
|
||||
do $$
|
||||
declare tbl text;
|
||||
begin
|
||||
foreach tbl in array array[
|
||||
'omt_send_details','omt_receive_details','bill_payment_details',
|
||||
'recharge_details','goods_sale_details','repair_details'
|
||||
] loop
|
||||
execute format('drop policy if exists %I_select on app.%I;', tbl, tbl);
|
||||
execute format($p$
|
||||
create policy %I_select on app.%I
|
||||
for select to authenticated
|
||||
using (app._can_see_txn(txn_id));
|
||||
$p$, tbl, tbl);
|
||||
|
||||
execute format('drop policy if exists %I_insert on app.%I;', tbl, tbl);
|
||||
execute format($p$
|
||||
create policy %I_insert on app.%I
|
||||
for insert to authenticated
|
||||
with check (app._can_write_detail(txn_id));
|
||||
$p$, tbl, tbl);
|
||||
|
||||
execute format('grant select, insert on app.%I to authenticated;', tbl);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- End migration 0004 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user