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