From 2d749869499faf0dc49198a15398f7ec9e3b538a Mon Sep 17 00:00:00 2001 From: namdamdoi68-oss Date: Fri, 31 Jul 2026 18:37:17 +0700 Subject: [PATCH] feat: structuring-detection AML rule with amount-clustering heuristic Signed-off-by: namdamdoi68-oss --- src/aml/ruleEvaluator.test.ts | 1475 +++++++++++++++++++++------------ src/aml/ruleEvaluator.ts | 525 +++++++++--- src/aml/types.ts | 108 ++- 3 files changed, 1436 insertions(+), 672 deletions(-) diff --git a/src/aml/ruleEvaluator.test.ts b/src/aml/ruleEvaluator.test.ts index 307f87f6..c3c75287 100644 --- a/src/aml/ruleEvaluator.test.ts +++ b/src/aml/ruleEvaluator.test.ts @@ -1,14 +1,15 @@ /** * Rule Evaluator Tests - * + * * Comprehensive test coverage for AML rule evaluation engine. * Tests velocity, structuring, geo-mismatch, and amount threshold rules. */ -import { RuleEvaluator, InMemoryVelocityRepository } from './ruleEvaluator'; -import { AMLRule, TransactionContext, InvestmentVelocityRecord } from './types'; -import { InvestmentRepository } from '../db/repositories/investmentRepository'; -import { MetricsCollector } from '../lib/metrics'; +import { RuleEvaluator, InMemoryVelocityRepository } from "./ruleEvaluator"; +import { AMLRule, TransactionContext, InvestmentVelocityRecord } from "./types"; +import { InvestmentRepository } from "../db/repositories/investmentRepository"; +import { MetricsCollector } from "../lib/metrics"; +import * as fc from "fast-check"; // Mock InvestmentRepository class MockInvestmentRepository { @@ -19,14 +20,15 @@ class MockInvestmentRepository { } async listByInvestor(options: any): Promise { - return this.investments.filter(inv => - inv.investor_id === options.investor_id && - (!options.offering_id || inv.offering_id === options.offering_id) + return this.investments.filter( + (inv) => + inv.investor_id === options.investor_id && + (!options.offering_id || inv.offering_id === options.offering_id), ); } } -describe('RuleEvaluator', () => { +describe("RuleEvaluator", () => { let evaluator: RuleEvaluator; let mockRepo: MockInvestmentRepository; @@ -35,15 +37,15 @@ describe('RuleEvaluator', () => { evaluator = new RuleEvaluator(mockRepo as any); }); - describe('Velocity Rule Evaluation', () => { - it('should trigger when transaction count exceeds limit', async () => { + describe("Velocity Rule Evaluation", () => { + it("should trigger when transaction count exceeds limit", async () => { const rule: AMLRule = { - id: 'rule1', - name: 'High Velocity', - description: 'Detects high transaction frequency', - type: 'velocity', + id: "rule1", + name: "High Velocity", + description: "Detects high transaction frequency", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, @@ -55,57 +57,57 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), previous_transactions: [ { - investment_id: 'inv2', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv2", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 30 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv3', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv3", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 25 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv4', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv4", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 20 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv5', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv5", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 15 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv6', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv6", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 10 * 60 * 1000), - status: 'completed', + status: "completed", }, ], }; @@ -116,14 +118,14 @@ describe('RuleEvaluator', () => { expect(results[0].details.count_exceeded).toBe(true); }); - it('should trigger when total amount exceeds limit', async () => { + it("should trigger when total amount exceeds limit", async () => { const rule: AMLRule = { - id: 'rule1', - name: 'High Velocity', - description: 'Detects high transaction amount', - type: 'velocity', + id: "rule1", + name: "High Velocity", + description: "Detects high transaction amount", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, @@ -135,21 +137,21 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '600', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "600", + asset: "USD", timestamp: new Date(), previous_transactions: [ { - investment_id: 'inv2', - investor_id: 'inv1', - offering_id: 'off1', - amount: '500', - asset: 'USD', + investment_id: "inv2", + investor_id: "inv1", + offering_id: "off1", + amount: "500", + asset: "USD", timestamp: new Date(Date.now() - 30 * 60 * 1000), - status: 'completed', + status: "completed", }, ], }; @@ -160,14 +162,14 @@ describe('RuleEvaluator', () => { expect(results[0].details.amount_exceeded).toBe(true); }); - it('should not trigger when within limits', async () => { + it("should not trigger when within limits", async () => { const rule: AMLRule = { - id: 'rule1', - name: 'High Velocity', - description: 'Detects high transaction frequency', - type: 'velocity', + id: "rule1", + name: "High Velocity", + description: "Detects high transaction frequency", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, @@ -179,11 +181,11 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -193,14 +195,14 @@ describe('RuleEvaluator', () => { expect(results[0].triggered).toBe(false); }); - it('should ignore failed transactions in velocity calculation', async () => { + it("should ignore failed transactions in velocity calculation", async () => { const rule: AMLRule = { - id: 'rule1', - name: 'High Velocity', - description: 'Detects high transaction frequency', - type: 'velocity', + id: "rule1", + name: "High Velocity", + description: "Detects high transaction frequency", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, @@ -212,30 +214,30 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), previous_transactions: [ { - investment_id: 'inv2', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv2", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 30 * 60 * 1000), - status: 'failed', + status: "failed", }, { - investment_id: 'inv3', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv3", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(Date.now() - 20 * 60 * 1000), - status: 'failed', + status: "failed", }, ], }; @@ -246,15 +248,15 @@ describe('RuleEvaluator', () => { }); }); - describe('Structuring Rule Evaluation', () => { - it('should detect transaction splitting', async () => { + describe("Structuring Rule Evaluation", () => { + it("should detect transaction splitting", async () => { const rule: AMLRule = { - id: 'rule2', - name: 'Structuring Detection', - description: 'Detects transaction splitting', - type: 'structuring', + id: "rule2", + name: "Structuring Detection", + description: "Detects transaction splitting", + type: "structuring", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_hours: 24, @@ -267,39 +269,39 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '3000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "3000", + asset: "USD", timestamp: new Date(), previous_transactions: [ { - investment_id: 'inv2', - investor_id: 'inv1', - offering_id: 'off1', - amount: '3050', - asset: 'USD', + investment_id: "inv2", + investor_id: "inv1", + offering_id: "off1", + amount: "3050", + asset: "USD", timestamp: new Date(Date.now() - 18 * 60 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv3', - investor_id: 'inv1', - offering_id: 'off1', - amount: '2950', - asset: 'USD', + investment_id: "inv3", + investor_id: "inv1", + offering_id: "off1", + amount: "2950", + asset: "USD", timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000), - status: 'completed', + status: "completed", }, { - investment_id: 'inv4', - investor_id: 'inv1', - offering_id: 'off1', - amount: '3020', - asset: 'USD', + investment_id: "inv4", + investor_id: "inv1", + offering_id: "off1", + amount: "3020", + asset: "USD", timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000), - status: 'completed', + status: "completed", }, ], }; @@ -310,14 +312,14 @@ describe('RuleEvaluator', () => { expect(results[0].details.similar_transaction_count).toBe(3); }); - it('should not trigger when below min transaction threshold', async () => { + it("should not trigger when below min transaction threshold", async () => { const rule: AMLRule = { - id: 'rule2', - name: 'Structuring Detection', - description: 'Detects transaction splitting', - type: 'structuring', + id: "rule2", + name: "Structuring Detection", + description: "Detects transaction splitting", + type: "structuring", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_hours: 24, @@ -330,21 +332,21 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '3000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "3000", + asset: "USD", timestamp: new Date(), previous_transactions: [ { - investment_id: 'inv2', - investor_id: 'inv1', - offering_id: 'off1', - amount: '3050', - asset: 'USD', + investment_id: "inv2", + investor_id: "inv1", + offering_id: "off1", + amount: "3050", + asset: "USD", timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000), - status: 'completed', + status: "completed", }, ], }; @@ -353,20 +355,291 @@ describe('RuleEvaluator', () => { expect(results).toHaveLength(1); expect(results[0].triggered).toBe(false); }); + + it("should detect deposits clustered just under reporting threshold ($9,900 smurfing pattern)", async () => { + const rule: AMLRule = { + id: "struct_rule_1", + name: "Structuring Amount Clustering", + description: "Detects smurfing deposits clustered under CTR threshold", + type: "structuring", + version: { major: 1, minor: 0, patch: 0 }, + severity: "critical", + enabled: true, + config: { + reporting_threshold: 10000, + cluster_lower_ratio: 0.8, + cluster_upper_ratio: 0.999, + min_cluster_count: 2, + score_threshold: 50, + window_days: 30, + }, + created_at: new Date(), + updated_at: new Date(), + }; + + const now = new Date(); + const context: TransactionContext = { + investment_id: "inv_smurf_1", + investor_id: "investor_smurf_1", + offering_id: "offering_1", + amount: "9900", + asset: "USD", + timestamp: now, + investor_country: "US", + previous_transactions: [ + { + investment_id: "inv_smurf_2", + investor_id: "investor_smurf_1", + offering_id: "offering_1", + amount: "9500", + asset: "USD", + timestamp: new Date(now.getTime() - 2 * 86400000), + status: "completed", + }, + { + investment_id: "inv_smurf_3", + investor_id: "investor_smurf_1", + offering_id: "offering_1", + amount: "9800", + asset: "USD", + timestamp: new Date(now.getTime() - 5 * 86400000), + status: "completed", + }, + ], + }; + + const results = await evaluator.evaluate(context, [rule]); + expect(results).toHaveLength(1); + const res = results[0]; + expect(res.triggered).toBe(true); + expect(res.details.clustered_count).toBe(3); + expect(res.details.clustered_total_amount).toBe(29200); + expect(typeof res.details.cluster_score).toBe("number"); + expect(res.details.cluster_score as number).toBeGreaterThanOrEqual(50); + expect(res.details.linked_investment_ids).toEqual( + expect.arrayContaining(["inv_smurf_2", "inv_smurf_3", "inv_smurf_1"]), + ); + }); + + it("refund protection: refunded or failed transactions do not distort cluster score", async () => { + const rule: AMLRule = { + id: "struct_rule_refund", + name: "Structuring Refund Protection", + description: "Verifies refunds are ignored in cluster score", + type: "structuring", + version: { major: 1, minor: 0, patch: 0 }, + severity: "high", + enabled: true, + config: { + reporting_threshold: 10000, + cluster_lower_ratio: 0.8, + cluster_upper_ratio: 0.999, + min_cluster_count: 2, + score_threshold: 50, + }, + created_at: new Date(), + updated_at: new Date(), + }; + + const now = new Date(); + const context: TransactionContext = { + investment_id: "inv_clean_1", + investor_id: "investor_refund_test", + offering_id: "offering_1", + amount: "1000", + asset: "USD", + timestamp: now, + previous_transactions: [ + { + investment_id: "inv_refunded_1", + investor_id: "investor_refund_test", + offering_id: "offering_1", + amount: "9900", + asset: "USD", + timestamp: new Date(now.getTime() - 1 * 86400000), + status: "refunded", + }, + { + investment_id: "inv_failed_1", + investor_id: "investor_refund_test", + offering_id: "offering_1", + amount: "9500", + asset: "USD", + timestamp: new Date(now.getTime() - 2 * 86400000), + status: "failed", + }, + ], + }; + + const results = await evaluator.evaluate(context, [rule]); + expect(results).toHaveLength(1); + const res = results[0]; + expect(res.triggered).toBe(false); + expect(res.details.clustered_count).toBe(0); + expect(res.details.cluster_score).toBe(0); + }); + + it("jurisdiction awareness: applies jurisdiction-specific reporting threshold (e.g. JP ¥1,000,000)", async () => { + const rule: AMLRule = { + id: "struct_rule_jp", + name: "Structuring JP Jurisdiction", + description: "Applies JP Yen reporting threshold", + type: "structuring", + version: { major: 1, minor: 0, patch: 0 }, + severity: "high", + enabled: true, + config: { + jurisdiction: "JP", + min_cluster_count: 2, + score_threshold: 50, + }, + created_at: new Date(), + updated_at: new Date(), + }; + + const now = new Date(); + const context: TransactionContext = { + investment_id: "inv_jp_1", + investor_id: "investor_jp", + offering_id: "offering_jp", + amount: "980000", + asset: "JPY", + investor_country: "JP", + timestamp: now, + previous_transactions: [ + { + investment_id: "inv_jp_2", + investor_id: "investor_jp", + offering_id: "offering_jp", + amount: "950000", + asset: "JPY", + timestamp: new Date(now.getTime() - 1 * 86400000), + status: "completed", + }, + ], + }; + + const results = await evaluator.evaluate(context, [rule]); + expect(results).toHaveLength(1); + const res = results[0]; + expect(res.triggered).toBe(true); + expect(res.details.reporting_threshold).toBe(1000000); + expect(res.details.jurisdiction).toBe("JP"); + expect(res.details.clustered_count).toBe(2); + }); + + it("emits aml.structuring.score gauge metric via MetricsCollector", async () => { + const metrics = new MetricsCollector({ enabled: true }); + const evaluatorWithMetrics = new RuleEvaluator( + mockRepo as unknown as InvestmentRepository, + { metrics }, + ); + + const rule: AMLRule = { + id: "struct_metric_rule", + name: "Structuring Metric Test", + description: "Verifies gauge metric emission", + type: "structuring", + version: { major: 1, minor: 0, patch: 0 }, + severity: "high", + enabled: true, + config: { + reporting_threshold: 10000, + }, + created_at: new Date(), + updated_at: new Date(), + }; + + const context: TransactionContext = { + investment_id: "inv_m_1", + investor_id: "investor_metric_user", + offering_id: "off1", + amount: "9500", + asset: "USD", + timestamp: new Date(), + }; + + await evaluatorWithMetrics.evaluate(context, [rule]); + const snapshot = await metrics.getSnapshot( + null as unknown as Parameters[0], + ); + const gauge = snapshot.custom.find( + (p) => p.name === "aml_structuring_score", + ); + expect(gauge).toBeDefined(); + expect(gauge?.value).toBeGreaterThanOrEqual(0); + }); + + it("fast-check property-based test: cluster score is always bounded [0, 100]", async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + amount: fc.double({ min: 1, max: 50000, noNaN: true }), + status: fc.constantFrom< + "pending" | "completed" | "failed" | "refunded" + >("pending", "completed", "failed", "refunded"), + }), + { minLength: 0, maxLength: 20 }, + ), + async (sampleTxs) => { + const rule: AMLRule = { + id: "struct_fc", + name: "FC Structuring", + description: "Property test", + type: "structuring", + version: { major: 1, minor: 0, patch: 0 }, + severity: "medium", + enabled: true, + config: { reporting_threshold: 10000 }, + created_at: new Date(), + updated_at: new Date(), + }; + + const now = new Date(); + const prevTxs = sampleTxs.map((tx, idx) => ({ + investment_id: `inv_fc_${idx}`, + investor_id: "fc_user", + offering_id: "off1", + amount: String(tx.amount), + asset: "USD", + timestamp: new Date(now.getTime() - idx * 3600000), + status: tx.status, + })); + + const context: TransactionContext = { + investment_id: "inv_fc_curr", + investor_id: "fc_user", + offering_id: "off1", + amount: "9500", + asset: "USD", + timestamp: now, + previous_transactions: prevTxs, + }; + + const results = await evaluator.evaluate(context, [rule]); + const score = results[0].details.cluster_score as number; + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(100); + }, + ), + { numRuns: 50 }, + ); + }); }); - describe('Geo-Mismatch Rule Evaluation', () => { - it('should trigger on country mismatch', async () => { + describe("Geo-Mismatch Rule Evaluation", () => { + it("should trigger on country mismatch", async () => { const rule: AMLRule = { - id: 'rule3', - name: 'Geo Mismatch', - description: 'Detects geographic inconsistencies', - type: 'geo_mismatch', + id: "rule3", + name: "Geo Mismatch", + description: "Detects geographic inconsistencies", + type: "geo_mismatch", version: { major: 1, minor: 0, patch: 0 }, - severity: 'medium', + severity: "medium", enabled: true, config: { - high_risk_countries: ['XX', 'YY'], + high_risk_countries: ["XX", "YY"], max_country_changes: 3, }, created_at: new Date(), @@ -374,14 +647,14 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), - investor_country: 'US', - investor_ip_country: 'GB', + investor_country: "US", + investor_ip_country: "GB", previous_transactions: [], }; @@ -391,17 +664,17 @@ describe('RuleEvaluator', () => { expect(results[0].details.is_mismatch).toBe(true); }); - it('should trigger on high-risk country', async () => { + it("should trigger on high-risk country", async () => { const rule: AMLRule = { - id: 'rule3', - name: 'Geo Mismatch', - description: 'Detects geographic inconsistencies', - type: 'geo_mismatch', + id: "rule3", + name: "Geo Mismatch", + description: "Detects geographic inconsistencies", + type: "geo_mismatch", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { - high_risk_countries: ['XX', 'YY'], + high_risk_countries: ["XX", "YY"], max_country_changes: 3, }, created_at: new Date(), @@ -409,14 +682,14 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), - investor_country: 'XX', - investor_ip_country: 'XX', + investor_country: "XX", + investor_ip_country: "XX", previous_transactions: [], }; @@ -426,17 +699,17 @@ describe('RuleEvaluator', () => { expect(results[0].details.is_high_risk).toBe(true); }); - it('should not trigger without geo data', async () => { + it("should not trigger without geo data", async () => { const rule: AMLRule = { - id: 'rule3', - name: 'Geo Mismatch', - description: 'Detects geographic inconsistencies', - type: 'geo_mismatch', + id: "rule3", + name: "Geo Mismatch", + description: "Detects geographic inconsistencies", + type: "geo_mismatch", version: { major: 1, minor: 0, patch: 0 }, - severity: 'medium', + severity: "medium", enabled: true, config: { - high_risk_countries: ['XX', 'YY'], + high_risk_countries: ["XX", "YY"], max_country_changes: 3, }, created_at: new Date(), @@ -444,11 +717,11 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -456,19 +729,19 @@ describe('RuleEvaluator', () => { const results = await evaluator.evaluate(context, [rule]); expect(results).toHaveLength(1); expect(results[0].triggered).toBe(false); - expect(results[0].details.reason).toBe('Insufficient geo data'); + expect(results[0].details.reason).toBe("Insufficient geo data"); }); }); - describe('Amount Threshold Rule Evaluation', () => { - it('should trigger when amount exceeds threshold', async () => { + describe("Amount Threshold Rule Evaluation", () => { + it("should trigger when amount exceeds threshold", async () => { const rule: AMLRule = { - id: 'rule4', - name: 'Amount Threshold', - description: 'Detects large single transactions', - type: 'amount_threshold', + id: "rule4", + name: "Amount Threshold", + description: "Detects large single transactions", + type: "amount_threshold", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { threshold: 10000, @@ -478,11 +751,11 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '15000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "15000", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -492,14 +765,14 @@ describe('RuleEvaluator', () => { expect(results[0].triggered).toBe(true); }); - it('should not trigger when amount below threshold', async () => { + it("should not trigger when amount below threshold", async () => { const rule: AMLRule = { - id: 'rule4', - name: 'Amount Threshold', - description: 'Detects large single transactions', - type: 'amount_threshold', + id: "rule4", + name: "Amount Threshold", + description: "Detects large single transactions", + type: "amount_threshold", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { threshold: 10000, @@ -509,11 +782,11 @@ describe('RuleEvaluator', () => { }; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '5000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "5000", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -524,17 +797,18 @@ describe('RuleEvaluator', () => { }); }); - describe('Sanctions Screening Rule Evaluation', () => { + describe("Sanctions Screening Rule Evaluation", () => { const sanctionsRule: AMLRule = { - id: 'sanctions_rule_1', - name: 'Sanctions Screening', - description: 'Screens investor against sanctions watchlist with Jaro-Winkler fuzzy matching', - type: 'sanctions_screening', + id: "sanctions_rule_1", + name: "Sanctions Screening", + description: + "Screens investor against sanctions watchlist with Jaro-Winkler fuzzy matching", + type: "sanctions_screening", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { - sanctions_list: ['Alexander Petrov', 'John Smith', 'Vladimir Putin'], + sanctions_list: ["Alexander Petrov", "John Smith", "Vladimir Putin"], jaro_winkler_threshold: 0.85, fuzzy_enabled: true, }, @@ -542,109 +816,113 @@ describe('RuleEvaluator', () => { updated_at: new Date(), }; - it('triggers exact match with auto_deny: true', async () => { + it("triggers exact match with auto_deny: true", async () => { const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'John Smith', + investor_name: "John Smith", }; const results = await evaluator.evaluate(context, [sanctionsRule]); expect(results).toHaveLength(1); expect(results[0].triggered).toBe(true); - expect(results[0].details.match_type).toBe('exact'); - expect(results[0].details.action).toBe('auto_deny'); + expect(results[0].details.match_type).toBe("exact"); + expect(results[0].details.action).toBe("auto_deny"); expect(results[0].details.auto_deny).toBe(true); }); - it('triggers fuzzy match with action: pending_review and auto_deny: false', async () => { + it("triggers fuzzy match with action: pending_review and auto_deny: false", async () => { const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'Aleksander Petrov', // Transliteration / spelling variation of Alexander Petrov + investor_name: "Aleksander Petrov", // Transliteration / spelling variation of Alexander Petrov }; const results = await evaluator.evaluate(context, [sanctionsRule]); expect(results).toHaveLength(1); expect(results[0].triggered).toBe(true); - expect(results[0].details.match_type).toBe('fuzzy'); - expect(results[0].details.action).toBe('pending_review'); + expect(results[0].details.match_type).toBe("fuzzy"); + expect(results[0].details.action).toBe("pending_review"); expect(results[0].details.auto_deny).toBe(false); - expect(results[0].details.review_status).toBe('pending_review'); + expect(results[0].details.review_status).toBe("pending_review"); }); - it('detects Cyrillic-to-Latin transliterations', async () => { + it("detects Cyrillic-to-Latin transliterations", async () => { const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'Александр Петров', // Cyrillic for Alexander Petrov + investor_name: "Александр Петров", // Cyrillic for Alexander Petrov }; const results = await evaluator.evaluate(context, [sanctionsRule]); expect(results).toHaveLength(1); expect(results[0].triggered).toBe(true); - expect(results[0].details.matched_candidate).toBe('Alexander Petrov'); - expect(results[0].details.action).toBe('pending_review'); + expect(results[0].details.matched_candidate).toBe("Alexander Petrov"); + expect(results[0].details.action).toBe("pending_review"); expect(results[0].details.auto_deny).toBe(false); }); - it('respects per-tenant threshold overrides in context.tenant_settings', async () => { + it("respects per-tenant threshold overrides in context.tenant_settings", async () => { const contextStrict: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'Jon Smithy', + investor_name: "Jon Smithy", tenant_settings: { sanctions_threshold: 0.95, // Very strict threshold }, }; - const resultsStrict = await evaluator.evaluate(contextStrict, [sanctionsRule]); + const resultsStrict = await evaluator.evaluate(contextStrict, [ + sanctionsRule, + ]); expect(resultsStrict[0].triggered).toBe(false); const contextPermissive: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'Jon Smithy', + investor_name: "Jon Smithy", tenant_settings: { sanctions_threshold: 0.75, // Lower threshold }, }; - const resultsPermissive = await evaluator.evaluate(contextPermissive, [sanctionsRule]); + const resultsPermissive = await evaluator.evaluate(contextPermissive, [ + sanctionsRule, + ]); expect(resultsPermissive[0].triggered).toBe(true); - expect(resultsPermissive[0].details.match_type).toBe('fuzzy'); - expect(resultsPermissive[0].details.action).toBe('pending_review'); + expect(resultsPermissive[0].details.match_type).toBe("fuzzy"); + expect(resultsPermissive[0].details.action).toBe("pending_review"); }); - it('does not trigger for unrelated names below threshold', async () => { + it("does not trigger for unrelated names below threshold", async () => { const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '1000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "1000", + asset: "USD", timestamp: new Date(), - investor_name: 'Robert Johnson', + investor_name: "Robert Johnson", }; const results = await evaluator.evaluate(context, [sanctionsRule]); @@ -653,28 +931,28 @@ describe('RuleEvaluator', () => { }); }); - describe('Multiple Rule Evaluation', () => { - it('should evaluate multiple rules and return all results', async () => { + describe("Multiple Rule Evaluation", () => { + it("should evaluate multiple rules and return all results", async () => { const rules: AMLRule[] = [ { - id: 'rule1', - name: 'Amount Threshold', - description: 'Detects large transactions', - type: 'amount_threshold', + id: "rule1", + name: "Amount Threshold", + description: "Detects large transactions", + type: "amount_threshold", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { threshold: 10000 }, created_at: new Date(), updated_at: new Date(), }, { - id: 'rule2', - name: 'Velocity', - description: 'Detects high frequency', - type: 'velocity', + id: "rule2", + name: "Velocity", + description: "Detects high frequency", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, max_amount: 100000, max_count: 10 }, created_at: new Date(), @@ -683,30 +961,30 @@ describe('RuleEvaluator', () => { ]; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '15000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "15000", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; const results = await evaluator.evaluate(context, rules); expect(results).toHaveLength(2); - expect(results[0].rule_id).toBe('rule1'); - expect(results[1].rule_id).toBe('rule2'); + expect(results[0].rule_id).toBe("rule1"); + expect(results[1].rule_id).toBe("rule2"); }); - it('should skip disabled rules', async () => { + it("should skip disabled rules", async () => { const rules: AMLRule[] = [ { - id: 'rule1', - name: 'Amount Threshold', - description: 'Detects large transactions', - type: 'amount_threshold', + id: "rule1", + name: "Amount Threshold", + description: "Detects large transactions", + type: "amount_threshold", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: false, config: { threshold: 10000 }, created_at: new Date(), @@ -715,11 +993,11 @@ describe('RuleEvaluator', () => { ]; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '15000', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "15000", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -728,15 +1006,15 @@ describe('RuleEvaluator', () => { expect(results).toHaveLength(0); }); - it('should handle unknown rule types gracefully', async () => { + it("should handle unknown rule types gracefully", async () => { const rules: AMLRule[] = [ { - id: 'rule1', - name: 'Unknown Rule', - description: 'Unknown rule type', - type: 'unknown' as any, + id: "rule1", + name: "Unknown Rule", + description: "Unknown rule type", + type: "unknown" as any, version: { major: 1, minor: 0, patch: 0 }, - severity: 'low', + severity: "low", enabled: true, config: {}, created_at: new Date(), @@ -745,11 +1023,11 @@ describe('RuleEvaluator', () => { ]; const context: TransactionContext = { - investment_id: 'inv1', - investor_id: 'inv1', - offering_id: 'off1', - amount: '100', - asset: 'USD', + investment_id: "inv1", + investor_id: "inv1", + offering_id: "off1", + amount: "100", + asset: "USD", timestamp: new Date(), previous_transactions: [], }; @@ -757,7 +1035,7 @@ describe('RuleEvaluator', () => { const results = await evaluator.evaluate(context, rules); expect(results).toHaveLength(1); expect(results[0].triggered).toBe(false); - expect(results[0].details.error).toBe('Unknown rule type'); + expect(results[0].details.error).toBe("Unknown rule type"); }); }); }); @@ -773,40 +1051,43 @@ describe('RuleEvaluator', () => { * - entity_types filter prevents cross-type matches. * - All 13 scenarios are fully deterministic (no Date.now() variance). */ -describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { +describe("OFAC Counterparty Screening — Vessel & Aircraft", () => { let mockRepo: MockInvestmentRepository; let evaluator: RuleEvaluator; - const baseRule = (configOverrides: Record = {}): AMLRule => ({ - id: 'ofac-cp-rule-1', - name: 'OFAC Counterparty Screening', - description: 'Screens vessel/aircraft/organisation counterparties against OFAC SDN list', - type: 'ofac_counterparty_screening', + const baseRule = ( + configOverrides: Record = {}, + ): AMLRule => ({ + id: "ofac-cp-rule-1", + name: "OFAC Counterparty Screening", + description: + "Screens vessel/aircraft/organisation counterparties against OFAC SDN list", + type: "ofac_counterparty_screening", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { sanctions_list: [ - 'Arktika Star', // sanctioned vessel - 'Petrov Cargo LLC', // sanctioned organisation - 'Firebird One', // sanctioned aircraft - 'Global Maritime Corp', // vessel name variant + "Arktika Star", // sanctioned vessel + "Petrov Cargo LLC", // sanctioned organisation + "Firebird One", // sanctioned aircraft + "Global Maritime Corp", // vessel name variant ], jaro_winkler_threshold: 0.85, fuzzy_enabled: true, ...configOverrides, }, - created_at: new Date('2026-01-01T00:00:00Z'), - updated_at: new Date('2026-01-01T00:00:00Z'), + created_at: new Date("2026-01-01T00:00:00Z"), + updated_at: new Date("2026-01-01T00:00:00Z"), }); const baseContext = (counterparties: any[]): TransactionContext => ({ - investment_id: 'inv-ofac-1', - investor_id: 'investor-1', - offering_id: 'offering-1', - amount: '5000', - asset: 'USD', - timestamp: new Date('2026-06-01T10:00:00Z'), + investment_id: "inv-ofac-1", + investor_id: "investor-1", + offering_id: "offering-1", + amount: "5000", + asset: "USD", + timestamp: new Date("2026-06-01T10:00:00Z"), counterparties, }); @@ -817,72 +1098,70 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // ── 1. Vessel exact name match ──────────────────────────────────────────── - it('1: vessel exact name match → triggered=true, match_reason=ofac_vessel_exact', async () => { + it("1: vessel exact name match → triggered=true, match_reason=ofac_vessel_exact", async () => { const ctx = baseContext([ - { name: 'Arktika Star', type: 'vessel', imo_number: 'IMO1234567' }, + { name: "Arktika Star", type: "vessel", imo_number: "IMO1234567" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); expect(result.triggered).toBe(true); const matches = result.details.matches as any[]; expect(matches).toHaveLength(1); - expect(matches[0].match_reason).toBe('ofac_vessel_exact'); - expect(matches[0].entity_type).toBe('vessel'); - expect(matches[0].action).toBe('auto_deny'); + expect(matches[0].match_reason).toBe("ofac_vessel_exact"); + expect(matches[0].entity_type).toBe("vessel"); + expect(matches[0].action).toBe("auto_deny"); }); // ── 2. IMO number surfaced in match details ─────────────────────────────── - it('2: valid IMO number is surfaced in details.matches[0].imo_number', async () => { + it("2: valid IMO number is surfaced in details.matches[0].imo_number", async () => { const ctx = baseContext([ - { name: 'Arktika Star', type: 'vessel', imo_number: 'IMO9876543' }, + { name: "Arktika Star", type: "vessel", imo_number: "IMO9876543" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); const matches = result.details.matches as any[]; - expect(matches[0].imo_number).toBe('IMO9876543'); + expect(matches[0].imo_number).toBe("IMO9876543"); }); // ── 3. Aircraft fuzzy name match ────────────────────────────────────────── - it('3: aircraft fuzzy name match → triggered=true, action=pending_review', async () => { + it("3: aircraft fuzzy name match → triggered=true, action=pending_review", async () => { // 'Fireberd One' is a deliberate misspelling — Jaro-Winkler will score it // above 0.85 relative to 'Firebird One' but normalizeName will NOT reduce // it to an exact match, so it remains in the fuzzy code path. - const ctx = baseContext([ - { name: 'Fireberd One', type: 'aircraft' }, - ]); + const ctx = baseContext([{ name: "Fireberd One", type: "aircraft" }]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); expect(result.triggered).toBe(true); const matches = result.details.matches as any[]; - expect(matches[0].match_type).toBe('fuzzy'); - expect(matches[0].match_reason).toBe('ofac_aircraft_fuzzy'); - expect(matches[0].action).toBe('pending_review'); - expect(result.details.action).toBe('pending_review'); + expect(matches[0].match_type).toBe("fuzzy"); + expect(matches[0].match_reason).toBe("ofac_aircraft_fuzzy"); + expect(matches[0].action).toBe("pending_review"); + expect(result.details.action).toBe("pending_review"); }); // ── 4. Organisation exact match ─────────────────────────────────────────── - it('4: organisation exact name match → entity_type=organisation, triggered=true', async () => { + it("4: organisation exact name match → entity_type=organisation, triggered=true", async () => { const ctx = baseContext([ - { name: 'Petrov Cargo LLC', type: 'organisation' }, + { name: "Petrov Cargo LLC", type: "organisation" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); expect(result.triggered).toBe(true); const matches = result.details.matches as any[]; - expect(matches[0].entity_type).toBe('organisation'); - expect(matches[0].match_reason).toBe('ofac_organisation_exact'); + expect(matches[0].entity_type).toBe("organisation"); + expect(matches[0].match_reason).toBe("ofac_organisation_exact"); }); // ── 5. entity_types filter — vessel rule must NOT match aircraft counterparty - it('5: entity_types=[vessel] filter skips aircraft counterparty', async () => { + it("5: entity_types=[vessel] filter skips aircraft counterparty", async () => { const ctx = baseContext([ - { name: 'Firebird One', type: 'aircraft' }, // exact match — but filtered out + { name: "Firebird One", type: "aircraft" }, // exact match — but filtered out ]); - const rule = baseRule({ entity_types: ['vessel'] }); + const rule = baseRule({ entity_types: ["vessel"] }); const [result] = await evaluator.evaluate(ctx, [rule]); // Aircraft is excluded by the type filter; should not trigger. @@ -891,19 +1170,19 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // ── 6. Person-queue isolation ───────────────────────────────────────────── - it('6: counterparty with same name as SDN person does NOT trigger person-queue (sanctions_screening)', async () => { + it("6: counterparty with same name as SDN person does NOT trigger person-queue (sanctions_screening)", async () => { // Use a name that appears in both the counterparty list and a hypothetical // sanctions_screening rule to prove the rules are isolated code paths. const personRule: AMLRule = { - id: 'person-sanctions-rule', - name: 'Sanctions Screening — Persons', - description: 'Person-queue SDN screening', - type: 'sanctions_screening', + id: "person-sanctions-rule", + name: "Sanctions Screening — Persons", + description: "Person-queue SDN screening", + type: "sanctions_screening", version: { major: 1, minor: 0, patch: 0 }, - severity: 'critical', + severity: "critical", enabled: true, config: { - sanctions_list: ['Arktika Star'], // same name as vessel counterparty + sanctions_list: ["Arktika Star"], // same name as vessel counterparty jaro_winkler_threshold: 0.85, fuzzy_enabled: true, }, @@ -913,13 +1192,13 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // investor_name is not set — person-queue has nothing to screen. const ctx: TransactionContext = { - investment_id: 'inv-x', - investor_id: 'inv-x', - offering_id: 'off-x', - amount: '100', - asset: 'USD', + investment_id: "inv-x", + investor_id: "inv-x", + offering_id: "off-x", + amount: "100", + asset: "USD", timestamp: new Date(), - counterparties: [{ name: 'Arktika Star', type: 'vessel' }], + counterparties: [{ name: "Arktika Star", type: "vessel" }], // investor_name intentionally absent }; @@ -935,38 +1214,38 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // ── 7. Invalid IMO format — screened by name only, imo_number absent ────── - it('7: invalid IMO format is dropped; counterparty still screened by name', async () => { + it("7: invalid IMO format is dropped; counterparty still screened by name", async () => { const ctx = baseContext([ // 'VESSEL123' does not match /^IMO\d{7}$/ — should not appear in details. - { name: 'Arktika Star', type: 'vessel', imo_number: 'VESSEL123' }, + { name: "Arktika Star", type: "vessel", imo_number: "VESSEL123" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); - expect(result.triggered).toBe(true); // still matched by name + expect(result.triggered).toBe(true); // still matched by name const matches = result.details.matches as any[]; expect(matches[0].imo_number).toBeUndefined(); }); // ── 8. Empty counterparties array → not triggered ───────────────────────── - it('8: empty counterparties array → triggered=false, reason surfaced', async () => { + it("8: empty counterparties array → triggered=false, reason surfaced", async () => { const ctx = baseContext([]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); expect(result.triggered).toBe(false); expect(result.details.screened_count).toBe(0); - expect(result.details.reason).toBe('No counterparties to screen'); + expect(result.details.reason).toBe("No counterparties to screen"); }); // ── 9. Missing / undefined counterparties field → not triggered ─────────── - it('9: undefined counterparties field → triggered=false gracefully', async () => { + it("9: undefined counterparties field → triggered=false gracefully", async () => { const ctx: TransactionContext = { - investment_id: 'inv-2', - investor_id: 'investor-2', - offering_id: 'offering-2', - amount: '1000', - asset: 'USD', + investment_id: "inv-2", + investor_id: "investor-2", + offering_id: "offering-2", + amount: "1000", + asset: "USD", timestamp: new Date(), // counterparties intentionally absent }; @@ -977,10 +1256,10 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // ── 10. All counterparties clear → triggered=false ──────────────────────── - it('10: counterparties with no SDN match → triggered=false, screened_count correct', async () => { + it("10: counterparties with no SDN match → triggered=false, screened_count correct", async () => { const ctx = baseContext([ - { name: 'Clean Vessel Corp', type: 'vessel', imo_number: 'IMO0000001' }, - { name: 'Legitimate Airways Ltd', type: 'aircraft' }, + { name: "Clean Vessel Corp", type: "vessel", imo_number: "IMO0000001" }, + { name: "Legitimate Airways Ltd", type: "aircraft" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); @@ -991,11 +1270,11 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { // ── 11. Multiple counterparties with one hit ────────────────────────────── - it('11: one sanctioned counterparty among multiple clean ones → triggered, match_count=1', async () => { + it("11: one sanctioned counterparty among multiple clean ones → triggered, match_count=1", async () => { const ctx = baseContext([ - { name: 'Clean Ship A', type: 'vessel', imo_number: 'IMO1111111' }, - { name: 'Arktika Star', type: 'vessel', imo_number: 'IMO2222222' }, // hit - { name: 'Honest Aircraft Co', type: 'aircraft' }, + { name: "Clean Ship A", type: "vessel", imo_number: "IMO1111111" }, + { name: "Arktika Star", type: "vessel", imo_number: "IMO2222222" }, // hit + { name: "Honest Aircraft Co", type: "aircraft" }, ]); const [result] = await evaluator.evaluate(ctx, [baseRule()]); @@ -1003,64 +1282,68 @@ describe('OFAC Counterparty Screening — Vessel & Aircraft', () => { expect(result.details.screened_count).toBe(3); expect(result.details.match_count).toBe(1); const matches = result.details.matches as any[]; - expect(matches[0].screened_name).toBe('Arktika Star'); + expect(matches[0].screened_name).toBe("Arktika Star"); }); // ── 12. Per-tenant threshold applies to counterparty screening ──────────── - it('12: per-tenant threshold overrides rule config for counterparty fuzzy matching', async () => { + it("12: per-tenant threshold overrides rule config for counterparty fuzzy matching", async () => { // 'Arktika Ster' is a slight misspelling — fuzzy match at 0.85 threshold. const ctxStrict: TransactionContext = { - ...baseContext([{ name: 'Arktika Ster', type: 'vessel' }]), + ...baseContext([{ name: "Arktika Ster", type: "vessel" }]), tenant_settings: { sanctions_threshold: 0.99 }, // far too strict to match }; const ctxPermissive: TransactionContext = { - ...baseContext([{ name: 'Arktika Ster', type: 'vessel' }]), - tenant_settings: { sanctions_threshold: 0.70 }, // permissive — should match + ...baseContext([{ name: "Arktika Ster", type: "vessel" }]), + tenant_settings: { sanctions_threshold: 0.7 }, // permissive — should match }; const [strictResult] = await evaluator.evaluate(ctxStrict, [baseRule()]); - const [permissiveResult] = await evaluator.evaluate(ctxPermissive, [baseRule()]); + const [permissiveResult] = await evaluator.evaluate(ctxPermissive, [ + baseRule(), + ]); expect(strictResult.triggered).toBe(false); expect(permissiveResult.triggered).toBe(true); const matches = permissiveResult.details.matches as any[]; - expect(matches[0].match_type).toBe('fuzzy'); + expect(matches[0].match_type).toBe("fuzzy"); }); // ── 13. fuzzy_enabled=false — only exact match triggers ─────────────────── - it('13: fuzzy_enabled=false → only exact match triggers, near-miss does not', async () => { + it("13: fuzzy_enabled=false → only exact match triggers, near-miss does not", async () => { const rule = baseRule({ fuzzy_enabled: false }); // Near-miss — would trigger with fuzzy enabled. - const nearMissCtx = baseContext([{ name: 'Arktika Ster', type: 'vessel' }]); + const nearMissCtx = baseContext([{ name: "Arktika Ster", type: "vessel" }]); const [nearMissResult] = await evaluator.evaluate(nearMissCtx, [rule]); expect(nearMissResult.triggered).toBe(false); // Exact match — must still trigger even with fuzzy disabled. - const exactCtx = baseContext([{ name: 'Arktika Star', type: 'vessel' }]); + const exactCtx = baseContext([{ name: "Arktika Star", type: "vessel" }]); const [exactResult] = await evaluator.evaluate(exactCtx, [rule]); expect(exactResult.triggered).toBe(true); const matches = exactResult.details.matches as any[]; - expect(matches[0].match_type).toBe('exact'); + expect(matches[0].match_type).toBe("exact"); }); }); // ─── Velocity rule — sliding window aggregation (smurfing detection) ────────── -describe('Velocity Rule — Sliding Window Aggregation', () => { +describe("Velocity Rule — Sliding Window Aggregation", () => { let mockRepo: MockInvestmentRepository; let velocityRepo: InMemoryVelocityRepository; let evaluator: RuleEvaluator; - const velocityRule = (overrides: Partial = {}): AMLRule => ({ - id: 'vel-rule-1', - name: 'Investment Velocity', - description: 'Detects smurfing via sliding-window aggregation', - type: 'velocity', + const velocityRule = ( + overrides: Partial = {}, + ): AMLRule => ({ + id: "vel-rule-1", + name: "Investment Velocity", + description: "Detects smurfing via sliding-window aggregation", + type: "velocity", version: { major: 1, minor: 0, patch: 0 }, - severity: 'high', + severity: "high", enabled: true, config: { window_minutes: 60, @@ -1076,13 +1359,13 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { id: string, amount: string, minutesAgo: number, - status: 'completed' | 'failed' | 'pending' = 'completed' + status: "completed" | "failed" | "pending" = "completed", ): TransactionContext => ({ investment_id: id, - investor_id: 'inv-1', - offering_id: 'off-1', + investor_id: "inv-1", + offering_id: "off-1", amount, - asset: 'USD', + asset: "USD", timestamp: new Date(Date.now() - minutesAgo * 60_000), status, }); @@ -1095,15 +1378,15 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { // ── Trigger conditions ──────────────────────────────────────────────────── - it('triggers when transaction count exceeds max_count', async () => { + it("triggers when transaction count exceeds max_count", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [ - makeTx('t1', '100', 50), - makeTx('t2', '100', 40), - makeTx('t3', '100', 30), - makeTx('t4', '100', 20), - makeTx('t5', '100', 10), + makeTx("t1", "100", 50), + makeTx("t2", "100", 40), + makeTx("t3", "100", 30), + makeTx("t4", "100", 20), + makeTx("t5", "100", 10), ], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); @@ -1111,53 +1394,53 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { expect(result.details.count_exceeded).toBe(true); }); - it('triggers when total amount exceeds max_amount', async () => { + it("triggers when total amount exceeds max_amount", async () => { const context: TransactionContext = { - ...makeTx('cur', '600', 0), - previous_transactions: [makeTx('t1', '500', 30)], + ...makeTx("cur", "600", 0), + previous_transactions: [makeTx("t1", "500", 30)], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); expect(result.triggered).toBe(true); expect(result.details.amount_exceeded).toBe(true); }); - it('does not trigger when both count and amount are within limits', async () => { + it("does not trigger when both count and amount are within limits", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), - previous_transactions: [makeTx('t1', '100', 30)], + ...makeTx("cur", "100", 0), + previous_transactions: [makeTx("t1", "100", 30)], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); expect(result.triggered).toBe(false); }); - it('ignores failed transactions when aggregating', async () => { + it("ignores failed transactions when aggregating", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [ - makeTx('t1', '400', 20, 'failed'), - makeTx('t2', '400', 10, 'failed'), + makeTx("t1", "400", 20, "failed"), + makeTx("t2", "400", 10, "failed"), ], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); expect(result.triggered).toBe(false); }); - it('ignores transactions outside the window', async () => { + it("ignores transactions outside the window", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [ - makeTx('t1', '900', 90), // 90 min ago — outside 60-min window + makeTx("t1", "900", 90), // 90 min ago — outside 60-min window ], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); expect(result.triggered).toBe(false); }); - it('only counts transactions strictly within [window_start, window_end]', async () => { + it("only counts transactions strictly within [window_start, window_end]", async () => { // Exactly at window boundary (60 min ago) should be included const context: TransactionContext = { - ...makeTx('cur', '100', 0), - previous_transactions: [makeTx('t1', '950', 60)], + ...makeTx("cur", "100", 0), + previous_transactions: [makeTx("t1", "950", 60)], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); // 950 + 100 = 1050 > 1000 → should trigger @@ -1167,65 +1450,65 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { // ── Details payload ─────────────────────────────────────────────────────── - it('includes window_start and window_end in details', async () => { + it("includes window_start and window_end in details", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); expect(result.details.window_start).toBeDefined(); expect(result.details.window_end).toBeDefined(); - expect(typeof result.details.window_start).toBe('string'); + expect(typeof result.details.window_start).toBe("string"); }); - it('includes linked_investment_ids in details', async () => { + it("includes linked_investment_ids in details", async () => { const context: TransactionContext = { - ...makeTx('cur', '200', 0), - previous_transactions: [makeTx('t1', '200', 30)], + ...makeTx("cur", "200", 0), + previous_transactions: [makeTx("t1", "200", 30)], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); const ids = result.details.linked_investment_ids as string[]; - expect(ids).toContain('t1'); - expect(ids).toContain('cur'); + expect(ids).toContain("t1"); + expect(ids).toContain("cur"); }); - it('linked_investment_ids contains only non-failed in-window investments', async () => { + it("linked_investment_ids contains only non-failed in-window investments", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [ - makeTx('in-window', '100', 30, 'completed'), - makeTx('failed-one', '100', 20, 'failed'), - makeTx('too-old', '100', 90, 'completed'), + makeTx("in-window", "100", 30, "completed"), + makeTx("failed-one", "100", 20, "failed"), + makeTx("too-old", "100", 90, "completed"), ], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); const ids = result.details.linked_investment_ids as string[]; - expect(ids).toContain('in-window'); - expect(ids).toContain('cur'); - expect(ids).not.toContain('failed-one'); - expect(ids).not.toContain('too-old'); + expect(ids).toContain("in-window"); + expect(ids).toContain("cur"); + expect(ids).not.toContain("failed-one"); + expect(ids).not.toContain("too-old"); }); // ── InMemoryVelocityRepository persistence ──────────────────────────────── - it('persists a velocity aggregate row after evaluation', async () => { + it("persists a velocity aggregate row after evaluation", async () => { const context: TransactionContext = { - ...makeTx('cur', '200', 0), - previous_transactions: [makeTx('t1', '300', 30)], + ...makeTx("cur", "200", 0), + previous_transactions: [makeTx("t1", "300", 30)], }; await evaluator.evaluate(context, [velocityRule()]); const rows = velocityRepo.all(); expect(rows).toHaveLength(1); - expect(rows[0].investor_id).toBe('inv-1'); + expect(rows[0].investor_id).toBe("inv-1"); expect(rows[0].tx_count).toBe(2); expect(rows[0].total_amount).toBe(500); - expect(rows[0].rule_id).toBe('vel-rule-1'); + expect(rows[0].rule_id).toBe("vel-rule-1"); }); - it('stores threshold_amount and threshold_count in persisted row', async () => { + it("stores threshold_amount and threshold_count in persisted row", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [], }; await evaluator.evaluate(context, [velocityRule()]); @@ -1234,24 +1517,27 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { expect(row.threshold_count).toBe(5); }); - it('upserts on late-arriving event — does not duplicate row', async () => { + it("upserts on late-arriving event — does not duplicate row", async () => { const now = new Date(); const windowEnd = now; const windowStart = new Date(now.getTime() - 60 * 60_000); // First evaluation const ctx1: TransactionContext = { - ...makeTx('cur', '200', 0), + ...makeTx("cur", "200", 0), timestamp: windowEnd, - previous_transactions: [makeTx('t1', '300', 30)], + previous_transactions: [makeTx("t1", "300", 30)], }; await evaluator.evaluate(ctx1, [velocityRule()]); // Late-arriving event with same window bounds — should upsert, not insert const ctx2: TransactionContext = { - ...makeTx('cur', '200', 0), + ...makeTx("cur", "200", 0), timestamp: windowEnd, - previous_transactions: [makeTx('t1', '300', 30), makeTx('t-late', '50', 25)], + previous_transactions: [ + makeTx("t1", "300", 30), + makeTx("t-late", "50", 25), + ], }; await evaluator.evaluate(ctx2, [velocityRule()]); @@ -1262,153 +1548,212 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { expect(rows[0].tx_count).toBe(3); // t1 + t-late + cur }); - it('findByInvestor returns rows in descending window_end order', async () => { - const t1 = new Date('2026-01-01T10:00:00Z'); - const t2 = new Date('2026-01-01T12:00:00Z'); + it("findByInvestor returns rows in descending window_end order", async () => { + const t1 = new Date("2026-01-01T10:00:00Z"); + const t2 = new Date("2026-01-01T12:00:00Z"); await velocityRepo.upsert({ - investor_id: 'inv-1', window_start: new Date('2026-01-01T09:00:00Z'), window_end: t1, - window_minutes: 60, tx_count: 1, total_amount: 100, investment_ids: ['a'], - amount_exceeded: false, count_exceeded: false, - threshold_amount: 1000, threshold_count: 5, - rule_id: 'r1', rule_version: { major: 1, minor: 0, patch: 0 }, + investor_id: "inv-1", + window_start: new Date("2026-01-01T09:00:00Z"), + window_end: t1, + window_minutes: 60, + tx_count: 1, + total_amount: 100, + investment_ids: ["a"], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: 1000, + threshold_count: 5, + rule_id: "r1", + rule_version: { major: 1, minor: 0, patch: 0 }, }); await velocityRepo.upsert({ - investor_id: 'inv-1', window_start: new Date('2026-01-01T11:00:00Z'), window_end: t2, - window_minutes: 60, tx_count: 2, total_amount: 200, investment_ids: ['b', 'c'], - amount_exceeded: false, count_exceeded: false, - threshold_amount: 1000, threshold_count: 5, - rule_id: 'r1', rule_version: { major: 1, minor: 0, patch: 0 }, + investor_id: "inv-1", + window_start: new Date("2026-01-01T11:00:00Z"), + window_end: t2, + window_minutes: 60, + tx_count: 2, + total_amount: 200, + investment_ids: ["b", "c"], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: 1000, + threshold_count: 5, + rule_id: "r1", + rule_version: { major: 1, minor: 0, patch: 0 }, }); - const rows = await velocityRepo.findByInvestor('inv-1', new Date('2026-01-01T00:00:00Z'), new Date('2026-01-02T00:00:00Z')); - expect(rows[0].window_end.getTime()).toBeGreaterThan(rows[1].window_end.getTime()); + const rows = await velocityRepo.findByInvestor( + "inv-1", + new Date("2026-01-01T00:00:00Z"), + new Date("2026-01-02T00:00:00Z"), + ); + expect(rows[0].window_end.getTime()).toBeGreaterThan( + rows[1].window_end.getTime(), + ); }); - it('findByInvestor filters by time range', async () => { + it("findByInvestor filters by time range", async () => { await velocityRepo.upsert({ - investor_id: 'inv-1', window_start: new Date('2026-01-01T09:00:00Z'), window_end: new Date('2026-01-01T10:00:00Z'), - window_minutes: 60, tx_count: 1, total_amount: 100, investment_ids: ['a'], - amount_exceeded: false, count_exceeded: false, - threshold_amount: 1000, threshold_count: 5, - rule_id: 'r1', rule_version: { major: 1, minor: 0, patch: 0 }, + investor_id: "inv-1", + window_start: new Date("2026-01-01T09:00:00Z"), + window_end: new Date("2026-01-01T10:00:00Z"), + window_minutes: 60, + tx_count: 1, + total_amount: 100, + investment_ids: ["a"], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: 1000, + threshold_count: 5, + rule_id: "r1", + rule_version: { major: 1, minor: 0, patch: 0 }, }); await velocityRepo.upsert({ - investor_id: 'inv-1', window_start: new Date('2025-12-31T09:00:00Z'), window_end: new Date('2025-12-31T10:00:00Z'), - window_minutes: 60, tx_count: 1, total_amount: 50, investment_ids: ['old'], - amount_exceeded: false, count_exceeded: false, - threshold_amount: 1000, threshold_count: 5, - rule_id: 'r1', rule_version: { major: 1, minor: 0, patch: 0 }, + investor_id: "inv-1", + window_start: new Date("2025-12-31T09:00:00Z"), + window_end: new Date("2025-12-31T10:00:00Z"), + window_minutes: 60, + tx_count: 1, + total_amount: 50, + investment_ids: ["old"], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: 1000, + threshold_count: 5, + rule_id: "r1", + rule_version: { major: 1, minor: 0, patch: 0 }, }); const rows = await velocityRepo.findByInvestor( - 'inv-1', - new Date('2026-01-01T00:00:00Z'), - new Date('2026-01-02T00:00:00Z') + "inv-1", + new Date("2026-01-01T00:00:00Z"), + new Date("2026-01-02T00:00:00Z"), ); expect(rows).toHaveLength(1); - expect(rows[0].investment_ids).toContain('a'); + expect(rows[0].investment_ids).toContain("a"); }); // ── Metrics emission ────────────────────────────────────────────────────── - it('emits aml_velocity_triggered_total counter on trigger', async () => { - const metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + it("emits aml_velocity_triggered_total counter on trigger", async () => { + const metrics = new MetricsCollector({ + enabled: true, + enablePIIDetection: false, + }); const eval2 = new RuleEvaluator(mockRepo as any, { velocityRepo, metrics }); const context: TransactionContext = { - ...makeTx('cur', '600', 0), - previous_transactions: [makeTx('t1', '500', 30)], + ...makeTx("cur", "600", 0), + previous_transactions: [makeTx("t1", "500", 30)], }; await eval2.evaluate(context, [velocityRule()]); const prom = metrics.exportPrometheus(); - expect(prom).toContain('aml_velocity_triggered_total'); + expect(prom).toContain("aml_velocity_triggered_total"); }); - it('does not emit counter when not triggered', async () => { - const metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + it("does not emit counter when not triggered", async () => { + const metrics = new MetricsCollector({ + enabled: true, + enablePIIDetection: false, + }); const eval2 = new RuleEvaluator(mockRepo as any, { velocityRepo, metrics }); const context: TransactionContext = { - ...makeTx('cur', '50', 0), + ...makeTx("cur", "50", 0), previous_transactions: [], }; await eval2.evaluate(context, [velocityRule()]); const prom = metrics.exportPrometheus(); - expect(prom).not.toContain('aml_velocity_triggered_total'); + expect(prom).not.toContain("aml_velocity_triggered_total"); }); - it('labels metric with reason=amount when only amount exceeded', async () => { - const metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + it("labels metric with reason=amount when only amount exceeded", async () => { + const metrics = new MetricsCollector({ + enabled: true, + enablePIIDetection: false, + }); const eval2 = new RuleEvaluator(mockRepo as any, { velocityRepo, metrics }); const context: TransactionContext = { - ...makeTx('cur', '900', 0), - previous_transactions: [makeTx('t1', '200', 30)], // total 1100 > 1000; count=2 ≤ 5 + ...makeTx("cur", "900", 0), + previous_transactions: [makeTx("t1", "200", 30)], // total 1100 > 1000; count=2 ≤ 5 }; await eval2.evaluate(context, [velocityRule()]); const snap = await metrics.getSnapshot(); - const counter = snap.custom.find((p: any) => - p.name === 'aml_velocity_triggered_total' && p.labels?.reason === 'amount' + const counter = snap.custom.find( + (p: any) => + p.name === "aml_velocity_triggered_total" && + p.labels?.reason === "amount", ); expect(counter).toBeDefined(); }); - it('labels metric with reason=count when only count exceeded', async () => { - const metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + it("labels metric with reason=count when only count exceeded", async () => { + const metrics = new MetricsCollector({ + enabled: true, + enablePIIDetection: false, + }); const eval2 = new RuleEvaluator(mockRepo as any, { velocityRepo, metrics }); // 6 transactions of $10 each — count 6 > 5, total $60 ≤ $1000 const context: TransactionContext = { - ...makeTx('cur', '10', 0), + ...makeTx("cur", "10", 0), previous_transactions: [ - makeTx('t1', '10', 50), - makeTx('t2', '10', 40), - makeTx('t3', '10', 30), - makeTx('t4', '10', 20), - makeTx('t5', '10', 10), + makeTx("t1", "10", 50), + makeTx("t2", "10", 40), + makeTx("t3", "10", 30), + makeTx("t4", "10", 20), + makeTx("t5", "10", 10), ], }; await eval2.evaluate(context, [velocityRule()]); const snap = await metrics.getSnapshot(); - const counter = snap.custom.find((p: any) => - p.name === 'aml_velocity_triggered_total' && p.labels?.reason === 'count' + const counter = snap.custom.find( + (p: any) => + p.name === "aml_velocity_triggered_total" && + p.labels?.reason === "count", ); expect(counter).toBeDefined(); }); - it('labels metric with reason=both when both exceeded', async () => { - const metrics = new MetricsCollector({ enabled: true, enablePIIDetection: false }); + it("labels metric with reason=both when both exceeded", async () => { + const metrics = new MetricsCollector({ + enabled: true, + enablePIIDetection: false, + }); const eval2 = new RuleEvaluator(mockRepo as any, { velocityRepo, metrics }); const context: TransactionContext = { - ...makeTx('cur', '300', 0), + ...makeTx("cur", "300", 0), previous_transactions: [ - makeTx('t1', '200', 50), - makeTx('t2', '200', 40), - makeTx('t3', '200', 30), - makeTx('t4', '200', 20), - makeTx('t5', '200', 10), + makeTx("t1", "200", 50), + makeTx("t2", "200", 40), + makeTx("t3", "200", 30), + makeTx("t4", "200", 20), + makeTx("t5", "200", 10), ], }; await eval2.evaluate(context, [velocityRule()]); const snap = await metrics.getSnapshot(); - const counter = snap.custom.find((p: any) => - p.name === 'aml_velocity_triggered_total' && p.labels?.reason === 'both' + const counter = snap.custom.find( + (p: any) => + p.name === "aml_velocity_triggered_total" && + p.labels?.reason === "both", ); expect(counter).toBeDefined(); }); // ── Case opening in AML service ─────────────────────────────────────────── - it('details include window_minutes from rule config', async () => { + it("details include window_minutes from rule config", async () => { const rule = velocityRule({ window_minutes: 30 }); const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [], }; const [result] = await evaluator.evaluate(context, [rule]); expect(result.details.window_minutes).toBe(30); }); - it('details include max_amount and max_count thresholds', async () => { + it("details include max_amount and max_count thresholds", async () => { const context: TransactionContext = { - ...makeTx('cur', '100', 0), + ...makeTx("cur", "100", 0), previous_transactions: [], }; const [result] = await evaluator.evaluate(context, [velocityRule()]); @@ -1418,43 +1763,43 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { // ── InMemoryVelocityRepository unit tests ───────────────────────────────── - describe('InMemoryVelocityRepository', () => { - it('stores and retrieves a record', async () => { + describe("InMemoryVelocityRepository", () => { + it("stores and retrieves a record", async () => { const repo = new InMemoryVelocityRepository(); const now = new Date(); const row = await repo.upsert({ - investor_id: 'i1', + investor_id: "i1", window_start: new Date(now.getTime() - 60_000), window_end: now, window_minutes: 1, tx_count: 1, total_amount: 100, - investment_ids: ['inv-1'], + investment_ids: ["inv-1"], amount_exceeded: false, count_exceeded: false, threshold_amount: 500, threshold_count: 10, - rule_id: 'r1', + rule_id: "r1", rule_version: { major: 1, minor: 0, patch: 0 }, }); expect(row.id).toBeTruthy(); expect(repo.all()).toHaveLength(1); }); - it('upsert updates existing row without creating a duplicate', async () => { + it("upsert updates existing row without creating a duplicate", async () => { const repo = new InMemoryVelocityRepository(); const now = new Date(); const base = { - investor_id: 'i1', + investor_id: "i1", window_start: new Date(now.getTime() - 60_000), window_end: now, window_minutes: 1, - investment_ids: ['inv-1'], + investment_ids: ["inv-1"], amount_exceeded: false, count_exceeded: false, threshold_amount: 500, threshold_count: 10, - rule_id: 'r1', + rule_id: "r1", rule_version: { major: 1, minor: 0, patch: 0 }, }; await repo.upsert({ ...base, tx_count: 1, total_amount: 100 }); @@ -1466,29 +1811,55 @@ describe('Velocity Rule — Sliding Window Aggregation', () => { expect(rows[0].total_amount).toBe(200); }); - it('clear empties the store', async () => { + it("clear empties the store", async () => { const repo = new InMemoryVelocityRepository(); const now = new Date(); await repo.upsert({ - investor_id: 'i1', window_start: new Date(now.getTime() - 60_000), window_end: now, - window_minutes: 1, tx_count: 1, total_amount: 10, investment_ids: [], - amount_exceeded: false, count_exceeded: false, threshold_amount: null, threshold_count: null, - rule_id: 'r', rule_version: { major: 1, minor: 0, patch: 0 }, + investor_id: "i1", + window_start: new Date(now.getTime() - 60_000), + window_end: now, + window_minutes: 1, + tx_count: 1, + total_amount: 10, + investment_ids: [], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: null, + threshold_count: null, + rule_id: "r", + rule_version: { major: 1, minor: 0, patch: 0 }, }); repo.clear(); expect(repo.all()).toHaveLength(0); }); - it('isolates records by investor_id', async () => { + it("isolates records by investor_id", async () => { const repo = new InMemoryVelocityRepository(); const now = new Date(); - const base = { window_start: new Date(now.getTime() - 60_000), window_end: now, window_minutes: 1, tx_count: 1, total_amount: 50, investment_ids: [], amount_exceeded: false, count_exceeded: false, threshold_amount: null, threshold_count: null, rule_id: 'r', rule_version: { major: 1, minor: 0, patch: 0 } }; - await repo.upsert({ ...base, investor_id: 'i1' }); - await repo.upsert({ ...base, investor_id: 'i2' }); - - const rows = await repo.findByInvestor('i1', new Date(0), new Date(Date.now() + 1000)); + const base = { + window_start: new Date(now.getTime() - 60_000), + window_end: now, + window_minutes: 1, + tx_count: 1, + total_amount: 50, + investment_ids: [], + amount_exceeded: false, + count_exceeded: false, + threshold_amount: null, + threshold_count: null, + rule_id: "r", + rule_version: { major: 1, minor: 0, patch: 0 }, + }; + await repo.upsert({ ...base, investor_id: "i1" }); + await repo.upsert({ ...base, investor_id: "i2" }); + + const rows = await repo.findByInvestor( + "i1", + new Date(0), + new Date(Date.now() + 1000), + ); expect(rows).toHaveLength(1); - expect(rows[0].investor_id).toBe('i1'); + expect(rows[0].investor_id).toBe("i1"); }); }); }); diff --git a/src/aml/ruleEvaluator.ts b/src/aml/ruleEvaluator.ts index ce3fcd04..8d88ece9 100644 --- a/src/aml/ruleEvaluator.ts +++ b/src/aml/ruleEvaluator.ts @@ -14,17 +14,13 @@ import { OfacScreeningMatch, OfacVesselAircraftRuleConfig, OfacEntityType, -} from './types'; -import { InvestmentRepository } from '../db/repositories/investmentRepository'; -import { jaroWinkler, normalizeName } from '../lib/jaroWinkler'; -import { MetricsCollector } from '../lib/metrics'; - -interface StructuringRuleConfig { - window_hours: number; - amount_threshold: number; - min_transactions: number; - reporting_threshold: number; -} + StructuringDetectionRuleConfig, + StructuringHistogramBucket, + StructuringClusterResult, +} from "./types"; +import { InvestmentRepository } from "../db/repositories/investmentRepository"; +import { jaroWinkler, normalizeName } from "../lib/jaroWinkler"; +import { MetricsCollector } from "../lib/metrics"; interface GeoMismatchRuleConfig { high_risk_countries: string[]; @@ -59,12 +55,17 @@ export class InMemoryVelocityRepository implements VelocityRepository { private store = new Map(); private idSeq = 0; - private key(r: Pick): string { + private key( + r: Pick< + InvestmentVelocityRecord, + "investor_id" | "window_start" | "window_end" | "rule_id" + >, + ): string { return `${r.investor_id}|${r.window_start.getTime()}|${r.window_end.getTime()}|${r.rule_id}`; } async upsert( - record: Omit + record: Omit, ): Promise { const k = this.key(record); const now = new Date(); @@ -90,12 +91,17 @@ export class InMemoryVelocityRepository implements VelocityRepository { return row; } - async findByInvestor(investorId: string, from: Date, to: Date): Promise { + async findByInvestor( + investorId: string, + from: Date, + to: Date, + ): Promise { return Array.from(this.store.values()) - .filter(r => - r.investor_id === investorId && - r.window_end >= from && - r.window_end <= to + .filter( + (r) => + r.investor_id === investorId && + r.window_end >= from && + r.window_end <= to, ) .sort((a, b) => b.window_end.getTime() - a.window_end.getTime()); } @@ -121,18 +127,26 @@ export class RuleEvaluator { options?: { velocityRepo?: VelocityRepository; metrics?: MetricsCollector; - } + }, ) { - this.velocityRepo = options?.velocityRepo ?? new InMemoryVelocityRepository(); + this.velocityRepo = + options?.velocityRepo ?? new InMemoryVelocityRepository(); this.metrics = options?.metrics; } - async evaluate(context: TransactionContext, rules: AMLRule[]): Promise { + async evaluate( + context: TransactionContext, + rules: AMLRule[], + ): Promise { const results: RuleEvaluationResult[] = []; - + // Use provided previous_transactions or fetch from repository if (!context.previous_transactions) { - context.previous_transactions = await this.getPreviousTransactions(context.investor_id, context.offering_id, 30); + context.previous_transactions = await this.getPreviousTransactions( + context.investor_id, + context.offering_id, + 30, + ); } for (const rule of rules) { @@ -143,34 +157,60 @@ export class RuleEvaluator { return results; } - private async evaluateRule(context: TransactionContext, rule: AMLRule): Promise { + private async evaluateRule( + context: TransactionContext, + rule: AMLRule, + ): Promise { let triggered = false; let details: Record = {}; switch (rule.type) { - case 'velocity': - ({ triggered, details } = await this.evaluateVelocityRule(context, rule)); + case "velocity": + ({ triggered, details } = await this.evaluateVelocityRule( + context, + rule, + )); break; - case 'structuring': + case "structuring": ({ triggered, details } = this.evaluateStructuringRule(context, rule)); break; - case 'geo_mismatch': + case "geo_mismatch": ({ triggered, details } = this.evaluateGeoMismatchRule(context, rule)); break; - case 'amount_threshold': - ({ triggered, details } = this.evaluateAmountThresholdRule(context, rule)); + case "amount_threshold": + ({ triggered, details } = this.evaluateAmountThresholdRule( + context, + rule, + )); break; - case 'sanctions_screening': + case "sanctions_screening": ({ triggered, details } = this.evaluateSanctionsRule(context, rule)); break; - case 'ofac_counterparty_screening': - ({ triggered, details } = this.evaluateOfacCounterpartyRule(context, rule)); + case "ofac_counterparty_screening": + ({ triggered, details } = this.evaluateOfacCounterpartyRule( + context, + rule, + )); break; default: - return { rule_id: rule.id, rule_version: rule.version, triggered: false, severity: rule.severity, details: { error: 'Unknown rule type' }, timestamp: new Date() }; + return { + rule_id: rule.id, + rule_version: rule.version, + triggered: false, + severity: rule.severity, + details: { error: "Unknown rule type" }, + timestamp: new Date(), + }; } - return { rule_id: rule.id, rule_version: rule.version, triggered, severity: rule.severity, details, timestamp: new Date() }; + return { + rule_id: rule.id, + rule_version: rule.version, + triggered, + severity: rule.severity, + details, + timestamp: new Date(), + }; } /** @@ -188,7 +228,7 @@ export class RuleEvaluator { */ private async evaluateVelocityRule( context: TransactionContext, - rule: AMLRule + rule: AMLRule, ): Promise<{ triggered: boolean; details: Record }> { const config = rule.config as unknown as VelocityRuleConfig; const transactions = context.previous_transactions ?? []; @@ -196,16 +236,22 @@ export class RuleEvaluator { // Build the window: [windowStart, context.timestamp] const windowEnd = new Date(context.timestamp); - const windowStart = new Date(windowEnd.getTime() - config.window_minutes * 60_000); + const windowStart = new Date( + windowEnd.getTime() - config.window_minutes * 60_000, + ); // Collect non-failed investments inside the window (excluding the current one). const recentTx = transactions.filter( - tx => tx.timestamp >= windowStart && - tx.timestamp <= windowEnd && - tx.status !== 'failed' + (tx) => + tx.timestamp >= windowStart && + tx.timestamp <= windowEnd && + tx.status !== "failed", ); - const windowTotal = recentTx.reduce((sum, tx) => sum + parseFloat(tx.amount), 0); + const windowTotal = recentTx.reduce( + (sum, tx) => sum + parseFloat(tx.amount), + 0, + ); const totalAmount = windowTotal + currentAmount; const txCount = recentTx.length + 1; // +1 for the current investment @@ -215,7 +261,7 @@ export class RuleEvaluator { // Persist the velocity aggregate (upsert handles late-arriving events). const linkedIds = [ - ...recentTx.map(tx => tx.investment_id), + ...recentTx.map((tx) => tx.investment_id), context.investment_id, ]; @@ -236,8 +282,13 @@ export class RuleEvaluator { }); if (triggered) { - const reason = amountExceeded && countExceeded ? 'both' : amountExceeded ? 'amount' : 'count'; - this.metrics?.incrementCounter('aml_velocity_triggered_total', { + const reason = + amountExceeded && countExceeded + ? "both" + : amountExceeded + ? "amount" + : "count"; + this.metrics?.incrementCounter("aml_velocity_triggered_total", { investor_id: context.investor_id, rule_id: rule.id, reason, @@ -262,67 +313,178 @@ export class RuleEvaluator { }; } - private evaluateStructuringRule(context: TransactionContext, rule: AMLRule): { triggered: boolean; details: Record } { - const config = rule.config as unknown as StructuringRuleConfig; - const transactions = context.previous_transactions || []; - const currentAmount = parseFloat(context.amount); - const windowStart = new Date(context.timestamp); - windowStart.setHours(windowStart.getHours() - config.window_hours); - const recentTransactions = transactions.filter(tx => tx.timestamp >= windowStart && tx.status !== 'failed'); - const similarTransactions = recentTransactions.filter(tx => Math.abs(parseFloat(tx.amount) - currentAmount) <= config.amount_threshold); - const totalAmount = similarTransactions.reduce((sum, tx) => sum + parseFloat(tx.amount), currentAmount); - const triggered = similarTransactions.length >= config.min_transactions && totalAmount > config.reporting_threshold; - return { triggered, details: { window_hours: config.window_hours, similar_transaction_count: similarTransactions.length, total_amount: totalAmount, reporting_threshold: config.reporting_threshold, amount_threshold: config.amount_threshold } }; + private evaluateStructuringRule( + context: TransactionContext, + rule: AMLRule, + ): { triggered: boolean; details: Record } { + const config = rule.config as unknown as StructuringDetectionRuleConfig; + + // Evaluate using deposit histogram and amount-clustering heuristic + const clusterResult = StructuringHistogramHelper.evaluate(context, config); + + // Legacy fallback check for backward compatibility with simple amount_threshold config + let legacyTriggered = false; + let similarCount = 0; + if ( + typeof config.amount_threshold === "number" && + typeof config.window_hours === "number" + ) { + const transactions = context.previous_transactions || []; + const currentAmount = parseFloat(context.amount); + const windowStart = new Date(context.timestamp); + windowStart.setHours(windowStart.getHours() - config.window_hours); + const recentTransactions = transactions.filter( + (tx) => + tx.timestamp >= windowStart && + tx.status !== "failed" && + tx.status !== "refunded", + ); + const similarTransactions = recentTransactions.filter( + (tx) => + Math.abs(parseFloat(tx.amount) - currentAmount) <= + (config.amount_threshold ?? 0), + ); + similarCount = similarTransactions.length; + const totalAmount = similarTransactions.reduce( + (sum, tx) => sum + parseFloat(tx.amount), + currentAmount, + ); + const minTx = config.min_transactions ?? 2; + const repThresh = config.reporting_threshold ?? 10000; + legacyTriggered = + similarTransactions.length >= minTx && totalAmount > repThresh; + } + + const minClusterCount = + config.min_cluster_count ?? config.min_transactions ?? 2; + const heuristicTriggered = + clusterResult.clustered_count >= minClusterCount && + clusterResult.cluster_score >= clusterResult.score_threshold; + + const triggered = heuristicTriggered || legacyTriggered; + + // Emit gauge metric: aml.structuring.score + this.metrics?.setGauge( + "aml.structuring.score", + clusterResult.cluster_score, + { + investor_id: context.investor_id, + rule_id: rule.id, + jurisdiction: clusterResult.jurisdiction, + }, + ); + + return { + triggered, + details: { + cluster_score: clusterResult.cluster_score, + score_threshold: clusterResult.score_threshold, + clustered_count: clusterResult.clustered_count, + clustered_total_amount: clusterResult.clustered_total_amount, + total_deposits_count: clusterResult.total_deposits_count, + total_deposits_amount: clusterResult.total_deposits_amount, + reporting_threshold: clusterResult.reporting_threshold, + jurisdiction: clusterResult.jurisdiction, + histogram_buckets: clusterResult.histogram_buckets, + linked_investment_ids: clusterResult.linked_investment_ids, + window_hours: config.window_hours, + similar_transaction_count: similarCount, + }, + }; } - private evaluateGeoMismatchRule(context: TransactionContext, rule: AMLRule): { triggered: boolean; details: Record } { + private evaluateGeoMismatchRule( + context: TransactionContext, + rule: AMLRule, + ): { triggered: boolean; details: Record } { const config = rule.config as unknown as GeoMismatchRuleConfig; const transactions = context.previous_transactions || []; - if (!context.investor_country || !context.investor_ip_country) return { triggered: false, details: { reason: 'Insufficient geo data' } }; + if (!context.investor_country || !context.investor_ip_country) + return { triggered: false, details: { reason: "Insufficient geo data" } }; const isMismatch = context.investor_country !== context.investor_ip_country; - const isHighRiskCountry = config.high_risk_countries.includes(context.investor_ip_country); - const countryChanges = transactions.filter(tx => tx.investor_ip_country && tx.investor_ip_country !== context.investor_ip_country).length; - const triggered = isMismatch || isHighRiskCountry || countryChanges >= config.max_country_changes; - return { triggered, details: { investor_country: context.investor_country, ip_country: context.investor_ip_country, is_mismatch: isMismatch, is_high_risk: isHighRiskCountry, country_changes: countryChanges, max_country_changes: config.max_country_changes } }; + const isHighRiskCountry = config.high_risk_countries.includes( + context.investor_ip_country, + ); + const countryChanges = transactions.filter( + (tx) => + tx.investor_ip_country && + tx.investor_ip_country !== context.investor_ip_country, + ).length; + const triggered = + isMismatch || + isHighRiskCountry || + countryChanges >= config.max_country_changes; + return { + triggered, + details: { + investor_country: context.investor_country, + ip_country: context.investor_ip_country, + is_mismatch: isMismatch, + is_high_risk: isHighRiskCountry, + country_changes: countryChanges, + max_country_changes: config.max_country_changes, + }, + }; } - private evaluateAmountThresholdRule(context: TransactionContext, rule: AMLRule): { triggered: boolean; details: Record } { + private evaluateAmountThresholdRule( + context: TransactionContext, + rule: AMLRule, + ): { triggered: boolean; details: Record } { const config = rule.config as unknown as AmountThresholdConfig; const currentAmount = parseFloat(context.amount); const triggered = currentAmount > config.threshold; - return { triggered, details: { amount: currentAmount, threshold: config.threshold } }; + return { + triggered, + details: { amount: currentAmount, threshold: config.threshold }, + }; } - private evaluateSanctionsRule(context: TransactionContext, rule: AMLRule): { triggered: boolean; details: Record } { + private evaluateSanctionsRule( + context: TransactionContext, + rule: AMLRule, + ): { triggered: boolean; details: Record } { const config = rule.config as unknown as SanctionsRuleConfig; const sanctionsList = config.sanctions_list || []; const nameToScreen = context.investor_name || context.investor_id; if (!nameToScreen || sanctionsList.length === 0) { - return { triggered: false, details: { reason: 'Missing investor name or sanctions list' } }; + return { + triggered: false, + details: { reason: "Missing investor name or sanctions list" }, + }; } // Per-tenant threshold > rule config threshold > default 0.85 const tenantThreshold = context.tenant_settings?.sanctions_threshold; - const threshold = typeof tenantThreshold === 'number' - ? tenantThreshold - : (typeof config.jaro_winkler_threshold === 'number' ? config.jaro_winkler_threshold : 0.85); + const threshold = + typeof tenantThreshold === "number" + ? tenantThreshold + : typeof config.jaro_winkler_threshold === "number" + ? config.jaro_winkler_threshold + : 0.85; const normName = normalizeName(nameToScreen); - let bestMatch: { candidate: string; score: number; matchType: 'exact' | 'fuzzy' } | null = null; + let bestMatch: { + candidate: string; + score: number; + matchType: "exact" | "fuzzy"; + } | null = null; for (const candidate of sanctionsList) { const normCandidate = normalizeName(candidate); if (normName === normCandidate) { - bestMatch = { candidate, score: 1.0, matchType: 'exact' }; + bestMatch = { candidate, score: 1.0, matchType: "exact" }; break; } if (config.fuzzy_enabled !== false) { - const score = jaroWinkler(nameToScreen, candidate, { transliterate: true }); + const score = jaroWinkler(nameToScreen, candidate, { + transliterate: true, + }); if (score >= threshold) { if (!bestMatch || score > bestMatch.score) { - bestMatch = { candidate, score, matchType: 'fuzzy' }; + bestMatch = { candidate, score, matchType: "fuzzy" }; } } } @@ -340,8 +502,8 @@ export class RuleEvaluator { } // Every fuzzy hit is treated as a pending review, never an auto-deny. - const isFuzzy = bestMatch.matchType === 'fuzzy'; - const action = isFuzzy ? 'pending_review' : 'auto_deny'; + const isFuzzy = bestMatch.matchType === "fuzzy"; + const action = isFuzzy ? "pending_review" : "auto_deny"; const autoDeny = !isFuzzy; return { @@ -354,18 +516,26 @@ export class RuleEvaluator { threshold, action, auto_deny: autoDeny, - review_status: isFuzzy ? 'pending_review' : 'confirmed_deny', + review_status: isFuzzy ? "pending_review" : "confirmed_deny", }, }; } - private async getPreviousTransactions(investorId: string, offeringId: string, daysBack: number): Promise { - const investments = await this.investmentRepo.listByInvestor({ investor_id: investorId, offering_id: offeringId, limit: 100 }); + private async getPreviousTransactions( + investorId: string, + offeringId: string, + daysBack: number, + ): Promise { + const investments = await this.investmentRepo.listByInvestor({ + investor_id: investorId, + offering_id: offeringId, + limit: 100, + }); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysBack); return investments - .filter(inv => inv.created_at >= cutoffDate) - .map(inv => ({ + .filter((inv) => inv.created_at >= cutoffDate) + .map((inv) => ({ investment_id: inv.id, investor_id: inv.investor_id, offering_id: inv.offering_id, @@ -409,7 +579,7 @@ export class RuleEvaluator { */ private evaluateOfacCounterpartyRule( context: TransactionContext, - rule: AMLRule + rule: AMLRule, ): { triggered: boolean; details: Record } { const config = rule.config as unknown as OfacVesselAircraftRuleConfig; const sanctionsList = config.sanctions_list ?? []; @@ -419,20 +589,21 @@ export class RuleEvaluator { // Per-tenant threshold > rule config threshold > default 0.85 const tenantThreshold = context.tenant_settings?.sanctions_threshold; const threshold = - typeof tenantThreshold === 'number' + typeof tenantThreshold === "number" ? tenantThreshold - : typeof config.jaro_winkler_threshold === 'number' - ? config.jaro_winkler_threshold - : 0.85; + : typeof config.jaro_winkler_threshold === "number" + ? config.jaro_winkler_threshold + : 0.85; if (counterparties.length === 0 || sanctionsList.length === 0) { return { triggered: false, details: { screened_count: 0, - reason: counterparties.length === 0 - ? 'No counterparties to screen' - : 'No sanctions list configured', + reason: + counterparties.length === 0 + ? "No counterparties to screen" + : "No sanctions list configured", }, }; } @@ -449,8 +620,8 @@ export class RuleEvaluator { // An invalid IMO is NOT a screening error — the counterparty is still // screened by name. The malformed value is simply not echoed to details. const validatedImo = - cp.type === 'vessel' && - typeof cp.imo_number === 'string' && + cp.type === "vessel" && + typeof cp.imo_number === "string" && RuleEvaluator.IMO_PATTERN.test(cp.imo_number) ? cp.imo_number : undefined; @@ -459,29 +630,31 @@ export class RuleEvaluator { let bestMatch: { candidate: string; score: number; - matchType: 'exact' | 'fuzzy'; + matchType: "exact" | "fuzzy"; } | null = null; for (const candidate of sanctionsList) { const normCandidate = normalizeName(candidate); if (normName === normCandidate) { - bestMatch = { candidate, score: 1.0, matchType: 'exact' }; + bestMatch = { candidate, score: 1.0, matchType: "exact" }; break; // Exact match is definitive; skip remaining candidates. } if (config.fuzzy_enabled !== false) { - const score = jaroWinkler(cp.name, candidate, { transliterate: true }); + const score = jaroWinkler(cp.name, candidate, { + transliterate: true, + }); if (score >= threshold) { if (!bestMatch || score > bestMatch.score) { - bestMatch = { candidate, score, matchType: 'fuzzy' }; + bestMatch = { candidate, score, matchType: "fuzzy" }; } } } } if (bestMatch) { - const isFuzzy = bestMatch.matchType === 'fuzzy'; + const isFuzzy = bestMatch.matchType === "fuzzy"; const match: OfacScreeningMatch = { screened_name: cp.name, entity_type: cp.type, @@ -490,7 +663,7 @@ export class RuleEvaluator { match_type: bestMatch.matchType, // Format: ofac__ match_reason: `ofac_${cp.type}_${bestMatch.matchType}`, - action: isFuzzy ? 'pending_review' : 'auto_deny', + action: isFuzzy ? "pending_review" : "auto_deny", ...(validatedImo !== undefined ? { imo_number: validatedImo } : {}), }; matches.push(match); @@ -509,9 +682,9 @@ export class RuleEvaluator { } // Determine overall action: if any match is auto_deny, surface that. - const overallAction = matches.some(m => m.action === 'auto_deny') - ? 'auto_deny' - : 'pending_review'; + const overallAction = matches.some((m) => m.action === "auto_deny") + ? "auto_deny" + : "pending_review"; return { triggered: true, @@ -526,3 +699,159 @@ export class RuleEvaluator { }; } } + +// ─── StructuringHistogramHelper ──────────────────────────────────────────────── + +/** + * Helper class to construct deposit amount histograms and compute per-investor + * cluster scores for structuring (smurfing) detection. + */ +export class StructuringHistogramHelper { + /** Default jurisdiction reporting thresholds in base units (USD / EUR / etc) */ + public static readonly DEFAULT_JURISDICTION_THRESHOLDS: Record< + string, + number + > = { + US: 10_000, + EU: 10_000, + UK: 10_000, + CA: 10_000, + AU: 10_000, + JP: 1_000_000, + GLOBAL: 10_000, + }; + + /** + * Evaluates transactions for an investor and computes deposit histogram + cluster score. + * + * @param context Current transaction context + * @param config Structuring detection rule configuration + * @returns StructuringClusterResult detailing score, buckets, and linked investments + */ + public static evaluate( + context: TransactionContext, + config: StructuringDetectionRuleConfig, + ): StructuringClusterResult { + const jurisdiction = + context.investor_country || config.jurisdiction || "US"; + const reportingThreshold = + config.jurisdiction_thresholds?.[jurisdiction] ?? + config.reporting_threshold ?? + StructuringHistogramHelper.DEFAULT_JURISDICTION_THRESHOLDS[ + jurisdiction + ] ?? + 10_000; + + const lowerRatio = config.cluster_lower_ratio ?? 0.8; + const upperRatio = config.cluster_upper_ratio ?? 0.999; + const lowerBandAmount = reportingThreshold * lowerRatio; + const upperBandAmount = reportingThreshold * upperRatio; + + // Window determination: window_days > window_hours / 24 > default 30 days + const windowDays = + config.window_days ?? + (config.window_hours ? config.window_hours / 24 : 30); + const windowEnd = new Date(context.timestamp); + const windowStart = new Date(windowEnd.getTime() - windowDays * 86_400_000); + + const history = context.previous_transactions ?? []; + const allCandidates = [...history, context]; + + // REFUND PROTECTION: Filter out failed, refunded, or negative/zero amounts. + const validDeposits = allCandidates.filter((tx) => { + if ( + !tx.timestamp || + tx.timestamp < windowStart || + tx.timestamp > windowEnd + ) { + return false; + } + if (tx.status === "failed" || tx.status === "refunded") { + return false; + } + const amt = parseFloat(tx.amount); + return !isNaN(amt) && amt > 0; + }); + + // Construct Histogram Buckets + const buckets: StructuringHistogramBucket[] = [ + { + label: `under_${Math.round(lowerBandAmount)}`, + min_amount: 0, + max_amount: lowerBandAmount, + count: 0, + total_amount: 0, + }, + { + label: `near_threshold_${Math.round(lowerBandAmount)}_${Math.round(upperBandAmount)}`, + min_amount: lowerBandAmount, + max_amount: upperBandAmount, + count: 0, + total_amount: 0, + }, + { + label: `above_threshold_${Math.round(reportingThreshold)}`, + min_amount: reportingThreshold, + max_amount: Number.POSITIVE_INFINITY, + count: 0, + total_amount: 0, + }, + ]; + + const clusteredInvestments: { id: string; amount: number }[] = []; + let totalDepositsAmount = 0; + + for (const tx of validDeposits) { + const amt = parseFloat(tx.amount); + totalDepositsAmount += amt; + + if (amt < lowerBandAmount) { + buckets[0].count++; + buckets[0].total_amount += amt; + } else if (amt >= lowerBandAmount && amt <= upperBandAmount) { + buckets[1].count++; + buckets[1].total_amount += amt; + clusteredInvestments.push({ id: tx.investment_id, amount: amt }); + } else { + buckets[2].count++; + buckets[2].total_amount += amt; + } + } + + const clusteredCount = buckets[1].count; + const clusteredTotalAmount = buckets[1].total_amount; + const totalDepositsCount = validDeposits.length; + const minClusterCount = + config.min_cluster_count ?? config.min_transactions ?? 2; + const scoreThreshold = config.score_threshold ?? 50; + + // Compute Per-Investor Cluster Score (0-100) + let clusterScore = 0; + if (totalDepositsCount > 0 && clusteredCount > 0) { + const concentrationRatio = clusteredCount / totalDepositsCount; + const volumeFactor = Math.min( + 1.0, + clusteredCount / Math.max(1, minClusterCount), + ); + const avgClustered = clusteredTotalAmount / clusteredCount; + const proximityRatio = Math.min(1.0, avgClustered / reportingThreshold); + + const rawScore = + concentrationRatio * 40 + volumeFactor * 40 + proximityRatio * 20; + clusterScore = Math.min(100, Math.max(0, Math.round(rawScore))); + } + + return { + cluster_score: clusterScore, + score_threshold: scoreThreshold, + clustered_count: clusteredCount, + clustered_total_amount: clusteredTotalAmount, + total_deposits_count: totalDepositsCount, + total_deposits_amount: totalDepositsAmount, + reporting_threshold: reportingThreshold, + jurisdiction, + histogram_buckets: buckets, + linked_investment_ids: clusteredInvestments.map((c) => c.id), + }; + } +} diff --git a/src/aml/types.ts b/src/aml/types.ts index 8958cee1..f7e9f07e 100644 --- a/src/aml/types.ts +++ b/src/aml/types.ts @@ -1,6 +1,6 @@ /** * AML Transaction Monitoring Types - * + * * Provides type-safe interfaces for AML rule definitions, * evaluation context, and case management workflow. */ @@ -9,12 +9,12 @@ * AML Rule Types - supported detection patterns */ export type AMLRuleType = - | 'velocity' // High transaction frequency/amount in time window - | 'structuring' // Breaking large transactions into smaller ones - | 'geo_mismatch' // Geographic inconsistency in transactions - | 'amount_threshold' // Single transaction exceeds threshold - | 'sanctions_screening' // Sanctions list screening — person queue (exact & Jaro-Winkler fuzzy) - | 'ofac_counterparty_screening'; // OFAC screening for non-person counterparty metadata (vessels, aircraft, organisations) + | "velocity" // High transaction frequency/amount in time window + | "structuring" // Breaking large transactions into smaller ones + | "geo_mismatch" // Geographic inconsistency in transactions + | "amount_threshold" // Single transaction exceeds threshold + | "sanctions_screening" // Sanctions list screening — person queue (exact & Jaro-Winkler fuzzy) + | "ofac_counterparty_screening"; // OFAC screening for non-person counterparty metadata (vessels, aircraft, organisations) /** * OFAC entity taxonomy — covers the four entity classes that appear on the @@ -22,7 +22,7 @@ export type AMLRuleType = * * @see https://ofac.treasury.gov/faqs/topic/1521 */ -export type OfacEntityType = 'person' | 'vessel' | 'aircraft' | 'organisation'; +export type OfacEntityType = "person" | "vessel" | "aircraft" | "organisation"; /** * A single counterparty attached to an offering that must be screened against @@ -57,7 +57,7 @@ export interface OfacScreeningMatch { /** Jaro-Winkler similarity or 1.0 for exact matches. */ similarity_score: number; /** 'exact' = normalised string equality; 'fuzzy' = Jaro-Winkler above threshold. */ - match_type: 'exact' | 'fuzzy'; + match_type: "exact" | "fuzzy"; /** * Human-readable match reason recorded on the alert, e.g. * 'ofac_vessel_exact', 'ofac_aircraft_fuzzy', 'ofac_organisation_exact'. @@ -65,23 +65,25 @@ export interface OfacScreeningMatch { */ match_reason: string; /** Analyst action: 'auto_deny' (exact match) or 'pending_review' (fuzzy). */ - action: 'auto_deny' | 'pending_review'; + action: "auto_deny" | "pending_review"; } /** * Rule severity levels for prioritization */ -export type AMLSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type AMLSeverity = "low" | "medium" | "high" | "critical"; /** * Case workflow statuses */ -export type AMLCaseStatus = 'open' | 'assigned' | 'investigating' | 'closed' | 'dismissed'; +export type AMLCaseStatus = + "open" | "assigned" | "investigating" | "closed" | "dismissed"; /** * Case disposition outcomes */ -export type AMLDisposition = 'confirmed_suspicious' | 'false_positive' | 'inconclusive' | 'legitimate'; +export type AMLDisposition = + "confirmed_suspicious" | "false_positive" | "inconclusive" | "legitimate"; /** * Semver version for rule versioning @@ -122,7 +124,7 @@ export interface TransactionContext { investor_country?: string; investor_ip_country?: string; previous_transactions?: TransactionContext[]; - status?: 'pending' | 'completed' | 'failed'; + status?: "pending" | "completed" | "failed" | "refunded"; tenant_id?: string; tenant_settings?: { sanctions_threshold?: number; @@ -185,7 +187,7 @@ export interface AMLAlert { rule_version: SemVer; severity: AMLSeverity; details: Record; - status: 'pending' | 'reviewed' | 'dismissed'; + status: "pending" | "reviewed" | "dismissed"; case_id?: string; created_at: Date; updated_at: Date; @@ -275,6 +277,62 @@ export interface UpdateCaseInput { notes?: string; } +/** + * Configuration for structuring detection rule with amount-clustering heuristic. + * Stored in AMLRule.config for rules of type 'structuring'. + */ +export interface StructuringDetectionRuleConfig { + /** Rolling window length in days (e.g. 30 days). Defaults to 30 if window_hours is omitted. */ + window_days?: number; + /** Rolling window length in hours (optional alternate unit). */ + window_hours?: number; + /** Primary regulatory reporting threshold (e.g. 10000 for USD). Defaults to 10000. */ + reporting_threshold?: number; + /** Lower ratio for near-threshold cluster band (e.g. 0.8 -> $8,000 for $10,000 threshold). Defaults to 0.8. */ + cluster_lower_ratio?: number; + /** Upper ratio for near-threshold cluster band (e.g. 0.999 -> $9,999.99 for $10,000 threshold). Defaults to 0.999. */ + cluster_upper_ratio?: number; + /** Minimum number of transactions in cluster band required to trigger alert. Defaults to 2. */ + min_cluster_count?: number; + /** Score threshold (0-100) above which rule triggers alert. Defaults to 50. */ + score_threshold?: number; + /** Jurisdiction identifier for jurisdiction-aware reporting thresholds (e.g. 'US', 'EU', 'JP'). Defaults to 'US'. */ + jurisdiction?: string; + /** Optional dictionary of jurisdiction-specific reporting thresholds. */ + jurisdiction_thresholds?: Record; + /** Minimum transaction amount filter for similar transaction check (fallback). */ + amount_threshold?: number; + /** Minimum transaction count filter (fallback). */ + min_transactions?: number; +} + +/** + * Single bucket in the deposit amount histogram. + */ +export interface StructuringHistogramBucket { + label: string; + min_amount: number; + max_amount: number; + count: number; + total_amount: number; +} + +/** + * Result of deposit histogram clustering evaluation. + */ +export interface StructuringClusterResult { + cluster_score: number; + score_threshold: number; + clustered_count: number; + clustered_total_amount: number; + total_deposits_count: number; + total_deposits_amount: number; + reporting_threshold: number; + jurisdiction: string; + histogram_buckets: StructuringHistogramBucket[]; + linked_investment_ids: string[]; +} + /** * Configuration for the sliding-window investment velocity rule. * Stored in AMLRule.config for rules of type 'velocity'. @@ -322,13 +380,19 @@ export interface VelocityRepository { * updated in-place so late-arriving events shift the window without * duplicating records. */ - upsert(record: Omit): Promise; + upsert( + record: Omit, + ): Promise; /** * Return all velocity records for an investor within the given time range, * ordered by window_end descending. */ - findByInvestor(investorId: string, from: Date, to: Date): Promise; + findByInvestor( + investorId: string, + from: Date, + to: Date, + ): Promise; } /** @@ -388,11 +452,11 @@ export interface AssignmentResult { * - `rejected` – manually rejected by a compliance officer. */ export type OFACReviewStatus = - | 'pending_first_approval' - | 'pending_second_approval' - | 'cleared' - | 'expired' - | 'rejected'; + | "pending_first_approval" + | "pending_second_approval" + | "cleared" + | "expired" + | "rejected"; /** * A persisted OFAC false-positive review case.