Add cash management schema and immediate variance alerts

This commit is contained in:
Krikorios
2026-05-06 10:51:55 +03:00
parent 1a3de58de6
commit 1896cbdd11
106 changed files with 16800 additions and 4604 deletions
@@ -0,0 +1,573 @@
-- =====================================================================
-- Migration 0009 — External reconciliation (roadmap Step 10).
--
-- The strongest fraud control is an external source of truth. Every
-- provider (OMT, Alfa, touch, Ogero, the bank, whish, the card terminal)
-- publishes a settlement statement; we import it line by line and match
-- each line to a local transaction by `external_ref`. Mismatches go to
-- `reconciliation_exceptions` and block month-close.
--
-- Threat-model rows addressed: 1, 5, 8, 21, 24.
-- =====================================================================
-- =====================================================================
-- Settlement headers + lines
-- =====================================================================
do $$ begin
create type app.settlement_provider as enum (
'OMT', 'ALFA', 'TOUCH', 'OGERO', 'WHISH', 'CARD_TERMINAL', 'BANK', 'WU'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_status as enum (
'imported', -- file parsed; matching not started
'matching', -- run is in progress
'matched', -- all lines matched, ready for sign-off
'has_exceptions', -- at least one line still unresolved
'closed' -- owner-signed off, immutable
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_line_status as enum (
'unmatched', -- no candidate found yet
'matched', -- exactly one candidate, amounts agree
'amount_mismatch', -- candidate found but money differs
'duplicate', -- the same external_ref already used elsewhere
'missing_local', -- provider has it; we don't
'extra_local' -- we have it; provider doesn't
);
exception when duplicate_object then null; end $$;
create table if not exists app.settlements (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
provider app.settlement_provider not null,
period_start date not null,
period_end date not null,
file_url text,
file_sha256 text,
total_amount numeric(20,2), -- as reported by provider
total_currency app.currency_code,
status app.settlement_status not null default 'imported',
imported_at timestamptz not null default now(),
imported_by uuid not null references auth.users(id) default auth.uid(),
closed_at timestamptz,
closed_by uuid references auth.users(id),
notes text,
constraint settlements_period_ok check (period_end >= period_start)
);
create index if not exists idx_settlements_shop_period
on app.settlements(shop_id, provider, period_start);
create table if not exists app.settlement_lines (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
-- Raw fields as parsed from the provider file:
external_ref text not null, -- provider txn id / receipt no
occurred_at timestamptz,
amount numeric(20,2) not null,
currency app.currency_code not null,
fee numeric(20,2),
commission numeric(20,2),
raw jsonb, -- the original parsed row
-- Match output:
matched_txn_id uuid references app.transactions(id),
status app.settlement_line_status not null default 'unmatched',
matched_at timestamptz,
-- A natural key per provider statement keeps imports idempotent.
unique (settlement_id, external_ref)
);
create index if not exists idx_settle_line_status on app.settlement_lines(settlement_id, status);
create index if not exists idx_settle_line_ref on app.settlement_lines(external_ref);
-- Exceptions queue. Every non-matched line generates a row here so the
-- owner has a single place to clear before closing the period.
create table if not exists app.reconciliation_exceptions (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
line_id uuid references app.settlement_lines(id) on delete cascade,
txn_id uuid references app.transactions(id),
type app.settlement_line_status not null,
detail text,
resolved_at timestamptz,
resolved_by uuid references auth.users(id),
resolution_note text,
created_at timestamptz not null default now()
);
create index if not exists idx_recon_exc_open
on app.reconciliation_exceptions(settlement_id) where resolved_at is null;
-- Late FK from float_movements (declared in 0005).
alter table app.float_movements
drop constraint if exists float_mov_settlement_fk;
alter table app.float_movements
add constraint float_mov_settlement_fk
foreign key (ref_settlement_id) references app.settlements(id) on delete restrict;
-- =====================================================================
-- Append-only behaviour where it matters
-- =====================================================================
-- Settlements: status moves are owner-driven via functions below.
create or replace function app._settlements_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'settlements cannot be deleted';
end if;
if old.status = 'closed' then
raise exception 'settlement % is closed and immutable', old.id;
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlements is not allowed; use app.* functions';
end if;
return new;
end;
$$;
drop trigger if exists trg_settlements_guard on app.settlements;
create trigger trg_settlements_guard before update or delete on app.settlements
for each row execute function app._settlements_guard();
-- Lines: insert at import time, matched in place by definer functions.
create or replace function app._settle_lines_guard()
returns trigger language plpgsql as $$
declare s app.settlements%rowtype;
begin
if tg_op = 'DELETE' then
raise exception 'settlement_lines cannot be deleted';
end if;
select * into s from app.settlements where id = coalesce(new.settlement_id, old.settlement_id);
if s.status = 'closed' then
raise exception 'cannot modify lines of a closed settlement';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlement_lines is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_settle_lines_guard on app.settlement_lines;
create trigger trg_settle_lines_guard before update or delete on app.settlement_lines
for each row execute function app._settle_lines_guard();
-- Exceptions: insert by matcher; resolution via definer.
create or replace function app._recon_exc_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'reconciliation_exceptions cannot be deleted';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on reconciliation_exceptions is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_recon_exc_guard on app.reconciliation_exceptions;
create trigger trg_recon_exc_guard before update or delete on app.reconciliation_exceptions
for each row execute function app._recon_exc_guard();
-- =====================================================================
-- Provider → service code map. Used by the matcher to know which local
-- service rows are eligible candidates for a given settlement file.
-- =====================================================================
create or replace function app._provider_service_codes(p app.settlement_provider)
returns text[]
language sql
immutable
as $$
select case p
when 'OMT' then array['OMT_SEND','OMT_RECEIVE','OMT_BILL']
when 'ALFA' then array['ALFA_RECHARGE']
when 'TOUCH' then array['TOUCH_RECHARGE']
when 'OGERO' then array['OGERO_RECHARGE','INTERNET_RECHARGE']
when 'WU' then array['WU_SEND','WU_RECEIVE']
-- BANK / WHISH / CARD_TERMINAL match by payment_method instead.
else null
end::text[];
$$;
-- =====================================================================
-- Import + match
-- =====================================================================
-- Insert one parsed row from the provider file. Idempotent by
-- (settlement_id, external_ref).
create or replace function app.add_settlement_line(
p_settlement uuid,
p_external_ref text,
p_occurred_at timestamptz,
p_amount numeric,
p_currency app.currency_code,
p_fee numeric,
p_commission numeric,
p_raw jsonb
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype; lid uuid;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then
raise exception 'settlement is closed';
end if;
insert into app.settlement_lines(settlement_id, external_ref, occurred_at,
amount, currency, fee, commission, raw)
values (p_settlement, p_external_ref, p_occurred_at,
p_amount, p_currency, p_fee, p_commission, p_raw)
on conflict (settlement_id, external_ref) do nothing
returning id into lid;
return lid;
end;
$$;
revoke all on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) from public;
grant execute on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) to authenticated;
-- Run the matcher across all unmatched lines of a settlement.
create or replace function app.run_match(p_settlement uuid)
returns table (matched int, exceptions int)
language plpgsql
security definer
set search_path = app, public
as $$
declare
s app.settlements%rowtype;
svc_codes text[];
ln app.settlement_lines%rowtype;
cand uuid;
cand_count int;
cand_amount_usd numeric;
cand_amount_lbp numeric;
cand_amount numeric;
ok_amount boolean;
m_count int := 0;
e_count int := 0;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then raise exception 'settlement is closed'; end if;
svc_codes := app._provider_service_codes(s.provider);
perform set_config('app.settle_internal', 'on', true);
update app.settlements set status = 'matching' where id = p_settlement;
for ln in
select * from app.settlement_lines
where settlement_id = p_settlement and status = 'unmatched'
loop
-- Find candidate(s) by external_ref + provider service codes (when
-- known) within the same shop, in completed state.
select count(*),
coalesce(min(t.id), null)
into cand_count, cand
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.external_ref = ln.external_ref
and (svc_codes is null or t.service_code = any(svc_codes));
if cand_count = 0 then
update app.settlement_lines
set status = 'missing_local'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'missing_local',
format('provider lists external_ref % but no local txn found', ln.external_ref));
e_count := e_count + 1;
elsif cand_count > 1 then
update app.settlement_lines
set status = 'duplicate'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'duplicate',
format('% local txns share external_ref %', cand_count, ln.external_ref));
e_count := e_count + 1;
else
-- Compare amounts within the matching currency. Allow 0.01 USD /
-- 100 LBP rounding tolerance.
select t.gross_usd, t.gross_lbp into cand_amount_usd, cand_amount_lbp
from app.transactions t where t.id = cand;
cand_amount := case ln.currency
when 'USD' then cand_amount_usd
when 'LBP' then cand_amount_lbp
end;
ok_amount := abs(coalesce(cand_amount,0) - coalesce(ln.amount,0))
<= case ln.currency when 'USD' then 0.01 else 100 end;
if ok_amount then
update app.settlement_lines
set status = 'matched',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
m_count := m_count + 1;
else
update app.settlement_lines
set status = 'amount_mismatch',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, txn_id, type, detail)
values (p_settlement, ln.id, cand, 'amount_mismatch',
format('local % %s vs provider % %s for ref %',
cand_amount, ln.currency, ln.amount, ln.currency, ln.external_ref));
e_count := e_count + 1;
end if;
end if;
end loop;
-- Now check for `extra_local`: completed local transactions in the
-- period whose external_ref is not present on the provider statement.
if svc_codes is not null then
insert into app.reconciliation_exceptions(settlement_id, txn_id, type, detail)
select p_settlement, t.id, 'extra_local',
format('local txn % has external_ref % but provider did not list it',
t.id, t.external_ref)
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.service_code = any(svc_codes)
and t.external_ref is not null
and (t.occurred_at at time zone 'UTC')::date between s.period_start and s.period_end
and not exists (
select 1 from app.settlement_lines sl
where sl.settlement_id = p_settlement
and sl.external_ref = t.external_ref
)
and not exists (
select 1 from app.reconciliation_exceptions r
where r.settlement_id = p_settlement
and r.txn_id = t.id
and r.type = 'extra_local'
);
get diagnostics e_count = row_count; -- approximate increment
end if;
-- Final status
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
update app.settlements set status = 'has_exceptions' where id = p_settlement;
else
update app.settlements set status = 'matched' where id = p_settlement;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_matched', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement,
'matched', m_count, 'exceptions', e_count));
matched := m_count; exceptions := e_count; return next;
end;
$$;
revoke all on function app.run_match(uuid) from public;
grant execute on function app.run_match(uuid) to authenticated;
-- Resolve a single exception. Owner-only with mandatory note.
create or replace function app.resolve_exception(
p_exception uuid,
p_note text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
e app.reconciliation_exceptions%rowtype;
s app.settlements%rowtype;
begin
if p_note is null or length(btrim(p_note)) < 5 then
raise exception 'resolution note >= 5 chars required';
end if;
select * into e from app.reconciliation_exceptions where id = p_exception;
if e.id is null then raise exception 'exception not found'; end if;
select * into s from app.settlements where id = e.settlement_id;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if e.resolved_at is not null then
raise exception 'exception already resolved';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.reconciliation_exceptions
set resolved_at = now(),
resolved_by = auth.uid(),
resolution_note = p_note
where id = p_exception;
-- If no open exceptions remain on this settlement, flip back to matched.
if not exists (
select 1 from app.reconciliation_exceptions
where settlement_id = s.id and resolved_at is null
) then
update app.settlements set status = 'matched' where id = s.id;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_exception_resolved', s.shop_id, null,
jsonb_build_object('exception_id', p_exception));
end;
$$;
revoke all on function app.resolve_exception(uuid, text) from public;
grant execute on function app.resolve_exception(uuid, text) to authenticated;
-- Close the settlement once all exceptions are resolved.
create or replace function app.close_settlement(p_settlement uuid)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if s.status not in ('matched') then
raise exception 'settlement must be in MATCHED state to close (was %)', s.status;
end if;
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
raise exception 'cannot close: open exceptions remain';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.settlements
set status = 'closed', closed_at = now(), closed_by = auth.uid()
where id = p_settlement;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_closed', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement));
end;
$$;
revoke all on function app.close_settlement(uuid) from public;
grant execute on function app.close_settlement(uuid) to authenticated;
-- =====================================================================
-- Reporting views
-- =====================================================================
create or replace view app.v_unmatched_external as
select s.shop_id, s.provider, s.period_start, s.period_end,
sl.external_ref, sl.amount, sl.currency, sl.status
from app.settlement_lines sl
join app.settlements s on s.id = sl.settlement_id
where sl.status <> 'matched';
create or replace view app.v_open_exceptions as
select s.shop_id, s.provider, s.period_start, s.period_end,
e.id as exception_id, e.type, e.detail, e.created_at
from app.reconciliation_exceptions e
join app.settlements s on s.id = e.settlement_id
where e.resolved_at is null;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.settlements enable row level security;
alter table app.settlement_lines enable row level security;
alter table app.reconciliation_exceptions enable row level security;
alter table app.settlements force row level security;
alter table app.settlement_lines force row level security;
alter table app.reconciliation_exceptions force row level security;
revoke insert, update, delete on app.settlements from authenticated;
revoke insert, update, delete on app.settlement_lines from authenticated;
revoke insert, update, delete on app.reconciliation_exceptions from authenticated;
drop policy if exists settle_select on app.settlements;
create policy settle_select on app.settlements
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.settlements to authenticated;
drop policy if exists settle_lines_select on app.settlement_lines;
create policy settle_lines_select on app.settlement_lines
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = settlement_lines.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.settlement_lines to authenticated;
drop policy if exists recon_exc_select on app.reconciliation_exceptions;
create policy recon_exc_select on app.reconciliation_exceptions
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = reconciliation_exceptions.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.reconciliation_exceptions to authenticated;
-- A small helper to create a settlement (owner/manager only).
create or replace function app.create_settlement(
p_shop uuid,
p_provider app.settlement_provider,
p_period_start date,
p_period_end date,
p_file_url text,
p_file_sha256 text,
p_total_amount numeric,
p_total_currency app.currency_code
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare sid uuid;
begin
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if p_period_end < p_period_start then
raise exception 'period_end before period_start';
end if;
insert into app.settlements(shop_id, provider, period_start, period_end,
file_url, file_sha256, total_amount, total_currency)
values (p_shop, p_provider, p_period_start, p_period_end,
p_file_url, p_file_sha256, p_total_amount, p_total_currency)
returning id into sid;
perform app.log_auth_event('settlement_imported', p_shop, null,
jsonb_build_object('settlement_id', sid, 'provider', p_provider));
return sid;
end;
$$;
revoke all on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) from public;
grant execute on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) to authenticated;
-- End migration 0009 ----------------------------------------------------