-- =====================================================================
-- Migration 0006 — Customers, KYC, and AML controls (roadmap Step 7).
--
-- Builds the customer/KYC layer that backs OMT send/receive and any
-- transfer above thresholds. Aggregation views detect structuring
-- (splitting a large transfer across multiple smaller ones).
--
-- Threat-model rows addressed: 1, 5, 16.
-- =====================================================================

-- =====================================================================
-- Customers
-- =====================================================================
create table if not exists app.customers (
  id              uuid primary key default gen_random_uuid(),
  full_name       text not null,
  id_type         app.id_doc_type not null,
  id_number       text not null,
  dob             date,
  nationality     text,
  phone           text,
  address         text,
  pep_flag        boolean not null default false,        -- politically exposed person
  sanctions_hit   boolean not null default false,
  sanctions_checked_at timestamptz,
  sanctions_source     text,                             -- which list / API
  notes           text,
  is_blocked      boolean not null default false,        -- owner can hard-block a customer
  blocked_reason  text,
  created_at      timestamptz not null default now(),
  created_by      uuid not null references auth.users(id) default auth.uid(),
  updated_at      timestamptz not null default now(),
  updated_by      uuid references auth.users(id),
  constraint customers_id_unique unique (id_type, id_number)
);
create index if not exists idx_customers_phone on app.customers(phone);
create index if not exists idx_customers_name  on app.customers(lower(full_name));

-- Stamp updated_*
create or replace function app._customers_stamp()
returns trigger language plpgsql as $$
begin
  if tg_op = 'INSERT' then
    new.created_by := auth.uid();
    new.created_at := now();
  end if;
  new.updated_by := auth.uid();
  new.updated_at := now();
  return new;
end;
$$;
drop trigger if exists trg_customers_stamp on app.customers;
create trigger trg_customers_stamp
  before insert or update on app.customers
  for each row execute function app._customers_stamp();

-- KYC documents (ID photos, declarations) -----------------------------
create table if not exists app.customer_documents (
  id          uuid primary key default gen_random_uuid(),
  customer_id uuid not null references app.customers(id) on delete restrict,
  doc_type    text not null,                       -- 'id_front','id_back','passport','declaration'
  file_url    text not null,
  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_customer_docs on app.customer_documents(customer_id);

-- Append-only customer-document table.
create or replace function app._customer_docs_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'customer_documents is append-only'; end;
$$;
drop trigger if exists trg_customer_docs_freeze on app.customer_documents;
create trigger trg_customer_docs_freeze before update or delete on app.customer_documents
  for each row execute function app._customer_docs_no_update_delete();

-- =====================================================================
-- Now that customers exists, attach the deferred FK from transactions.
-- =====================================================================
alter table app.transactions
  drop constraint if exists transactions_customer_fk;
alter table app.transactions
  add  constraint transactions_customer_fk
       foreign key (customer_id) references app.customers(id) on delete restrict;

-- =====================================================================
-- KYC thresholds (per service / currency). Server-controlled.
-- A txn at or above `daily_amount_warn` requires a customer record;
-- at or above `daily_amount_block` it is hard-blocked unless an owner
-- override is on file.
-- =====================================================================
create table if not exists app.kyc_thresholds (
  service_code        text not null references app.services(code),
  currency            app.currency_code not null,
  daily_amount_warn   numeric(18,2) not null check (daily_amount_warn  > 0),
  daily_amount_block  numeric(18,2) not null check (daily_amount_block > 0),
  primary key (service_code, currency),
  constraint kyc_thresholds_order check (daily_amount_block >= daily_amount_warn)
);

-- Sensible defaults. Owners can edit later.
insert into app.kyc_thresholds(service_code, currency, daily_amount_warn, daily_amount_block) values
  ('OMT_SEND',    'USD',   500,  10000),
  ('OMT_SEND',    'LBP', 45000000, 900000000),
  ('OMT_RECEIVE', 'USD',   500,  10000),
  ('OMT_RECEIVE', 'LBP', 45000000, 900000000),
  ('WU_SEND',     'USD',   500,  10000),
  ('WU_RECEIVE',  'USD',   500,  10000)
on conflict (service_code, currency) do nothing;

-- =====================================================================
-- Aggregation helper: customer's running daily total in a service
-- across the network (all shops).
-- =====================================================================
create or replace function app.customer_daily_total(
  p_customer  uuid,
  p_service   text,
  p_currency  app.currency_code,
  p_at        timestamptz default now()
) returns numeric
language sql
security definer
set search_path = app, public
stable
as $$
  select coalesce(sum(
    case when p_currency = 'USD' then t.gross_usd else t.gross_lbp end
  ), 0)
  from app.transactions t
  where t.customer_id  = p_customer
    and t.service_code = p_service
    and t.status       = 'completed'
    and t.occurred_at >= date_trunc('day', p_at)
    and t.occurred_at <  date_trunc('day', p_at) + interval '1 day';
$$;
revoke all on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) from public;
grant execute on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) to authenticated;

-- =====================================================================
-- KYC enforcement: check at transaction insert.
-- For OMT/WU services, if the txn amount alone or the customer's
-- running daily total crosses warn → customer mandatory; crosses block
-- → reject unless an owner override row is in place for the day.
-- =====================================================================
create table if not exists app.kyc_overrides (
  customer_id   uuid not null references app.customers(id),
  service_code  text not null references app.services(code),
  valid_for_day date not null,
  approved_by   uuid not null references auth.users(id),
  reason        text not null,
  created_at    timestamptz not null default now(),
  primary key (customer_id, service_code, valid_for_day)
);

create or replace function app._txn_enforce_kyc()
returns trigger
language plpgsql
as $$
declare
  th       app.kyc_thresholds%rowtype;
  amount_usd numeric := new.gross_usd;
  amount_lbp numeric := new.gross_lbp;
  daily_usd  numeric := 0;
  daily_lbp  numeric := 0;
  c          app.customers%rowtype;
begin
  if new.service_code not in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
    return new;
  end if;

  -- USD branch
  select * into th from app.kyc_thresholds
    where service_code = new.service_code and currency = 'USD';
  if found and amount_usd > 0 then
    if new.customer_id is not null then
      daily_usd := app.customer_daily_total(new.customer_id, new.service_code, 'USD', new.occurred_at);
    end if;
    if amount_usd + daily_usd >= th.daily_amount_warn and new.customer_id is null then
      raise exception 'KYC: customer record required at or above % USD/day for %',
        th.daily_amount_warn, new.service_code;
    end if;
    if amount_usd + daily_usd >= th.daily_amount_block then
      if new.customer_id is null
         or not exists (
           select 1 from app.kyc_overrides
           where customer_id = new.customer_id
             and service_code = new.service_code
             and valid_for_day = (new.occurred_at at time zone 'UTC')::date
         ) then
        raise exception 'KYC block: % USD/day exceeded for % (owner override required)',
          th.daily_amount_block, new.service_code;
      end if;
    end if;
  end if;

  -- LBP branch
  select * into th from app.kyc_thresholds
    where service_code = new.service_code and currency = 'LBP';
  if found and amount_lbp > 0 then
    if new.customer_id is not null then
      daily_lbp := app.customer_daily_total(new.customer_id, new.service_code, 'LBP', new.occurred_at);
    end if;
    if amount_lbp + daily_lbp >= th.daily_amount_warn and new.customer_id is null then
      raise exception 'KYC: customer record required at or above % LBP/day for %',
        th.daily_amount_warn, new.service_code;
    end if;
    if amount_lbp + daily_lbp >= th.daily_amount_block then
      if new.customer_id is null
         or not exists (
           select 1 from app.kyc_overrides
           where customer_id = new.customer_id
             and service_code = new.service_code
             and valid_for_day = (new.occurred_at at time zone 'UTC')::date
         ) then
        raise exception 'KYC block: % LBP/day exceeded for % (owner override required)',
          th.daily_amount_block, new.service_code;
      end if;
    end if;
  end if;

  -- Hard-blocked / sanctioned customers are never allowed.
  if new.customer_id is not null then
    select * into c from app.customers where id = new.customer_id;
    if c.is_blocked then
      raise exception 'customer is blocked: %', coalesce(c.blocked_reason, 'no reason');
    end if;
    if c.sanctions_hit then
      raise exception 'customer is on a sanctions list; transaction refused';
    end if;
  end if;

  return new;
end;
$$;

-- Run KYC checks after the txn_before_insert trigger has populated
-- shop_id/till_id/user_id/reference_no.
drop trigger if exists trg_txn_enforce_kyc on app.transactions;
create trigger trg_txn_enforce_kyc
  before insert on app.transactions
  for each row execute function app._txn_enforce_kyc();

-- =====================================================================
-- Structuring detection (vector #16):
-- A customer running a high cumulative OMT total via repeated small
-- transfers, or the same beneficiary phone receiving from many cashiers
-- in a short window. Exposed as views for the AML dashboard.
-- =====================================================================
create or replace view app.v_aml_structuring_by_customer as
with d as (
  select t.customer_id,
         t.service_code,
         (t.occurred_at at time zone 'UTC')::date as day,
         count(*)                                  as txn_count,
         sum(t.gross_usd)                          as total_usd,
         sum(t.gross_lbp)                          as total_lbp
  from app.transactions t
  where t.status = 'completed'
    and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
    and t.customer_id is not null
  group by 1,2,3
)
select d.*,
       (select daily_amount_warn from app.kyc_thresholds
          where service_code = d.service_code and currency = 'USD') as warn_usd,
       (select daily_amount_warn from app.kyc_thresholds
          where service_code = d.service_code and currency = 'LBP') as warn_lbp
from d
where d.txn_count >= 3                                  -- 3+ same-customer txns
  and (
       (d.total_usd >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
                                       where service_code = d.service_code and currency = 'USD'), 1e18))
    or (d.total_lbp >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
                                       where service_code = d.service_code and currency = 'LBP'), 1e18))
  );

create or replace view app.v_aml_same_beneficiary_burst as
select beneficiary_phone,
       date_trunc('hour', occurred_at) as hour_bucket,
       count(*)                        as txn_count,
       count(distinct user_id)         as distinct_cashiers,
       sum(gross_usd)                  as total_usd,
       sum(gross_lbp)                  as total_lbp
from app.transactions
where status = 'completed'
  and service_code in ('OMT_SEND','WU_SEND')
  and beneficiary_phone is not null
group by 1,2
having count(*) >= 3
   and count(distinct user_id) >= 2;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.customers          enable row level security;
alter table app.customer_documents enable row level security;
alter table app.kyc_thresholds     enable row level security;
alter table app.kyc_overrides      enable row level security;
alter table app.customers          force row level security;
alter table app.customer_documents force row level security;
alter table app.kyc_thresholds     force row level security;
alter table app.kyc_overrides      force row level security;

-- Customer rows: visible to anyone authenticated who actively uses
-- the system (cashiers need to find existing customers). Writes are
-- limited; deletion never permitted.
revoke delete on app.customers          from authenticated;
revoke update, delete on app.customer_documents from authenticated;
revoke insert, update, delete on app.kyc_thresholds from authenticated;
revoke update, delete on app.kyc_overrides from authenticated;

drop policy if exists customers_select on app.customers;
create policy customers_select on app.customers
  for select to authenticated using (true);

drop policy if exists customers_insert on app.customers;
create policy customers_insert on app.customers
  for insert to authenticated
  with check (auth.uid() is not null);

-- Restrict updates: cashiers may patch contact info; only owners may
-- toggle pep_flag, sanctions_hit, is_blocked. Enforced by trigger.
create or replace function app._customers_update_guard()
returns trigger language plpgsql as $$
begin
  if not app.is_owner_anywhere() then
    if new.pep_flag      is distinct from old.pep_flag
    or new.sanctions_hit is distinct from old.sanctions_hit
    or new.is_blocked    is distinct from old.is_blocked
    or coalesce(new.blocked_reason,'') <> coalesce(old.blocked_reason,'') then
      raise exception 'only an owner may change pep_flag, sanctions_hit, or is_blocked';
    end if;
  end if;
  return new;
end;
$$;
drop trigger if exists trg_customers_update_guard on app.customers;
create trigger trg_customers_update_guard
  before update on app.customers
  for each row execute function app._customers_update_guard();

drop policy if exists customers_update on app.customers;
create policy customers_update on app.customers
  for update to authenticated
  using (auth.uid() is not null)
  with check (auth.uid() is not null);

drop policy if exists customer_docs_select on app.customer_documents;
create policy customer_docs_select on app.customer_documents
  for select to authenticated using (true);
drop policy if exists customer_docs_insert on app.customer_documents;
create policy customer_docs_insert on app.customer_documents
  for insert to authenticated
  with check (auth.uid() is not null);
grant select, insert on app.customer_documents to authenticated;

drop policy if exists kyc_thr_select on app.kyc_thresholds;
create policy kyc_thr_select on app.kyc_thresholds
  for select to authenticated using (true);
-- thresholds are owner-only; until an owner-edit function lands, only
-- DBA can change them.

drop policy if exists kyc_ovr_select on app.kyc_overrides;
create policy kyc_ovr_select on app.kyc_overrides
  for select to authenticated
  using (app.is_owner_anywhere() or approved_by = auth.uid());
drop policy if exists kyc_ovr_insert on app.kyc_overrides;
create policy kyc_ovr_insert on app.kyc_overrides
  for insert to authenticated
  with check (app.is_owner_anywhere() and approved_by = auth.uid());
grant select, insert on app.kyc_overrides to authenticated;

grant select, insert, update on app.customers       to authenticated;
grant select                  on app.kyc_thresholds  to authenticated;

-- End migration 0006 ----------------------------------------------------
