Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions migrations/20260630_create_aml_screening_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
-- Migration: 20260630_create_aml_screening_tables
-- Description: Create aml_rules and aml_screening_results tables for
-- the configurable AML screening service with velocity counters.

-- ─── aml_rules ───────────────────────────────────────────────────────────────
-- Each row defines one screening rule.
-- rule_type drives which evaluation logic is used:
-- amount_threshold – block/flag if transaction amount >= config.threshold_xaf
-- velocity_check – block/flag if phone number has > config.max_count
-- transactions within config.window_seconds seconds
-- blacklisted_phone – block/flag if phone_number is in config.numbers[]

CREATE TYPE aml_rule_type AS ENUM (
'amount_threshold',
'velocity_check',
'blacklisted_phone'
);

CREATE TABLE IF NOT EXISTS aml_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_type aml_rule_type NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- JSONB payload; schema depends on rule_type (see docs per rule below)
config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aml_rules_rule_type ON aml_rules (rule_type);
CREATE INDEX IF NOT EXISTS idx_aml_rules_enabled ON aml_rules (enabled);

-- ─── aml_screening_results ───────────────────────────────────────────────────
-- One row per (transaction × rule) evaluation.
-- triggered = TRUE means the rule matched and the transaction was flagged.

CREATE TABLE IF NOT EXISTS aml_screening_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL, -- FK to transactions.id (soft – no FK for perf)
rule_id UUID NOT NULL, -- FK to aml_rules.id (soft)
rule_name VARCHAR(255) NOT NULL, -- snapshot of rule name at evaluation time
rule_type aml_rule_type NOT NULL, -- snapshot of rule type
triggered BOOLEAN NOT NULL DEFAULT FALSE,
-- Evaluation details: observed values, thresholds, etc.
details JSONB NOT NULL DEFAULT '{}',
screened_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aml_screening_transaction
ON aml_screening_results (transaction_id);
CREATE INDEX IF NOT EXISTS idx_aml_screening_rule
ON aml_screening_results (rule_id);
CREATE INDEX IF NOT EXISTS idx_aml_screening_triggered
ON aml_screening_results (triggered, screened_at DESC);
CREATE INDEX IF NOT EXISTS idx_aml_screening_screened_at
ON aml_screening_results (screened_at DESC);

-- Auto-update updated_at on aml_rules
CREATE OR REPLACE FUNCTION update_aml_rules_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_aml_rules_updated_at ON aml_rules;
CREATE TRIGGER trg_aml_rules_updated_at
BEFORE UPDATE ON aml_rules
FOR EACH ROW EXECUTE FUNCTION update_aml_rules_updated_at();

-- ─── Seed default rules ───────────────────────────────────────────────────────
-- These rules are active from first deployment. Operators can toggle enabled or
-- adjust config via the aml_rules table without code changes.

-- Rule 1: Large single-transaction threshold (1 000 000 XAF)
INSERT INTO aml_rules (rule_type, name, description, config, enabled)
VALUES (
'amount_threshold',
'Large Transaction Threshold',
'Flag any single transaction whose amount meets or exceeds 1 000 000 XAF (approx. $1 500 USD). '
'Matches the Central Bank of Central African States (BEAC) reporting threshold.',
'{"threshold_xaf": 1000000}',
TRUE
)
ON CONFLICT DO NOTHING;

-- Rule 2: Medium single-transaction alert (500 000 XAF) – lower severity flag
INSERT INTO aml_rules (rule_type, name, description, config, enabled)
VALUES (
'amount_threshold',
'Elevated Amount Alert',
'Flag transactions >= 500 000 XAF for enhanced due diligence. '
'Below the hard reporting threshold but warrants review.',
'{"threshold_xaf": 500000}',
TRUE
)
ON CONFLICT DO NOTHING;

-- Rule 3: Velocity check – more than 3 transactions from same phone number in 1 hour
INSERT INTO aml_rules (rule_type, name, description, config, enabled)
VALUES (
'velocity_check',
'High-Frequency Velocity Check',
'Flag when the same phone number initiates more than 3 transactions within a 1-hour '
'rolling window. Detects rapid structuring or compromised-account abuse.',
'{"max_count": 3, "window_seconds": 3600}',
TRUE
)
ON CONFLICT DO NOTHING;

-- Rule 4: Velocity check – more than 10 transactions from same phone in 24 hours
INSERT INTO aml_rules (rule_type, name, description, config, enabled)
VALUES (
'velocity_check',
'Daily Velocity Check',
'Flag when the same phone number initiates more than 10 transactions within 24 hours.',
'{"max_count": 10, "window_seconds": 86400}',
TRUE
)
ON CONFLICT DO NOTHING;

-- Rule 5: Blacklisted phone numbers (sample list – replace/extend via DB update)
INSERT INTO aml_rules (rule_type, name, description, config, enabled)
VALUES (
'blacklisted_phone',
'Blacklisted Phone Numbers',
'Reject any transaction from a phone number on the AML blacklist. '
'The numbers array is managed operationally and updated without a code deployment.',
'{"numbers": ["+237600000000", "+237611111111", "+237622222222"]}',
TRUE
)
ON CONFLICT DO NOTHING;

-- ─── Down migration (reference) ──────────────────────────────────────────────
-- To roll back:
-- DROP TABLE IF EXISTS aml_screening_results;
-- DROP TABLE IF EXISTS aml_rules;
-- DROP TYPE IF EXISTS aml_rule_type;
-- DROP FUNCTION IF EXISTS update_aml_rules_updated_at();
87 changes: 85 additions & 2 deletions src/controllers/transactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../config/providers";
import type { TransactionJobData } from "../queue/transactionQueue";
import { amlService } from "../services/aml";
import { amlScreeningService } from "../services/amlScreening";
import { generateFlaggedTransactionComplianceReport } from "../services/complianceReportService";
import { twoFactorWithdrawalService } from "../services/twoFactorWithdrawalService";
import {
Expand Down Expand Up @@ -585,14 +586,36 @@ async function processTransactionRequest(
}
}

// ── AML Screening (synchronous, before transaction creation) ─────
// Evaluate every enabled AML rule against this request. If any rule
// fires, the transaction is created with status=pending_review so it
// cannot be processed until a compliance officer approves it.
// screenTransaction() logs all evaluations to aml_screening_results
// after we have a real transaction ID (see persistTransactionId below).
const screeningOutcome = await amlScreeningService.screenTransaction({
transactionId: "pre-create", // placeholder; real ID is used after create
userId,
amount: requestAmount,
phoneNumber,
type,
});

const initialStatus = screeningOutcome.shouldFlag
? TransactionStatus.Review
: TransactionStatus.Pending;

const amlTags = screeningOutcome.shouldFlag
? ["aml-screened", "aml-flagged"]
: ["aml-screened"];

const transaction = await transactionModel.create({
type,
amount: String(amount),
phoneNumber,
provider,
stellarAddress,
status: TransactionStatus.Pending,
tags: [],
status: initialStatus,
tags: amlTags,
notes,
userId,
idempotencyKey,
Expand All @@ -601,6 +624,66 @@ async function processTransactionRequest(
: null,
locationMetadata: (req as any).geoLocation ?? null,
});

// Now that we have a real transaction ID, persist the screening results.
// This call is fire-and-forget inside screenTransaction; re-trigger with
// the real ID so the DB rows are correctly linked.
void amlScreeningService.screenTransaction(
{
transactionId: transaction.id,
userId,
amount: requestAmount,
phoneNumber,
type,
},
transaction.id,
).catch((err) => {
console.error(
`[AmlScreening] Failed to persist results for transaction ${transaction.id}:`,
err,
);
});

// Increment velocity counters for future screenings.
// Collect all unique window_seconds values from velocity rules so
// we only do one Redis round-trip per window.
void (async () => {
try {
const velocityRules = await amlScreeningService.loadRules();
const windows = [
...new Set(
velocityRules
.filter((r) => r.ruleType === "velocity_check")
.map((r) => Number(r.config["window_seconds"]))
.filter((w) => Number.isFinite(w) && w > 0),
),
];
if (windows.length > 0) {
await amlScreeningService.incrementVelocityCounters(phoneNumber, windows);
}
} catch (err) {
console.error("[AmlScreening] Failed to increment velocity counters:", err);
}
})();

if (screeningOutcome.shouldFlag) {
const matchedNames = screeningOutcome.matchedRules
.map((e) => e.rule.name)
.join(" | ");

void transactionModel
.updateAdminNotes(
transaction.id,
`[AML-SCREEN] Flagged by: ${matchedNames}`.slice(0, 1000),
)
.catch((err) => {
console.error(
"[AmlScreening] Failed to set admin notes:",
err,
);
});
}

void monitorTransactionForAML(transaction);
void applyTravelRule(transaction);

Expand Down
Loading
Loading