-- ===================================================================== -- 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 ----------------------------------------------------