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,376 @@
-- =====================================================================
-- 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 ----------------------------------------------------