Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -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 ----------------------------------------------------
|
||||
Reference in New Issue
Block a user