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