Skip to content

fix(kyc-fraud): Replace continent-heuristic geo anomaly detector with Haversine speed check + multi-hop sequence analysis - #210

Merged
BarryArinze merged 2 commits into
aid-linkk:masterfrom
Kekule17:master
Aug 26, 2026
Merged

fix(kyc-fraud): Replace continent-heuristic geo anomaly detector with Haversine speed check + multi-hop sequence analysis#210
BarryArinze merged 2 commits into
aid-linkk:masterfrom
Kekule17:master

Conversation

@Kekule17

Copy link
Copy Markdown
Contributor

Closes #198

Summary

Rewrites checkGeoAnomaly() in src/services/kycFraud.service.ts to eliminate four compounding bugs that caused frequent false positives against legitimate humanitarian aid beneficiaries — particularly refugees and displaced persons whose country-of-submission changes regularly and rapidly. A Kenyan family that fled to Uganda and submits a KYC from a Ugandan refugee camp would previously be flagged with severity: 'high' (cross-continent check) and, combined with any other signal, pushed above the highRiskThreshold = 50, triggering a FRAUD_DETECTION job and potentially causing auto-rejection.

The fix replaces the continent-membership heuristic with a physics-based impossible-travel detector: compute the great-circle distance between country centroids using the Haversine formula, derive implied speed, and compare against a configurable threshold (config.kycFraud.geoMaxPlausibleSpeedKmh, default 900 km/h). The continent map is retained as a low-precision fallback for country codes that are absent from the centroid dataset, but its severity output is capped at 'medium' to reflect reduced confidence.


Problem

Bug 1 — Continent membership instead of physical distance

// BEFORE (broken)
if (priorContinent && currContinent && priorContinent !== currContinent && hoursDiff < 2) {
  return { signal: 'geoAnomaly', severity: 'high', ... };
}

The detector compared continent strings rather than physical distance. Two countries on the same continent can be 8,000 km apart (Portugal and Russia are both EU in the hand-rolled map), while two countries on different continents can be 100 km apart (Morocco/Spain, Panama/Colombia). config.kycFraud.geoMaxPlausibleSpeedKmh = 900 was defined in config and parsed but never referenced anywhere in the code — it was clearly intended for a Haversine-based calculation that was never implemented.

Bug 2 — Incomplete and incorrect continent map

buildContinentMap() had multiple errors:

  • Cyprus (CY) appeared in both the AS and EU arrays; last-write won (EU), silently.
  • Kosovo (XK), Taiwan (TW), Palestine (PS), Western Sahara (EH) were absent.
  • Russia (RU) was placed in EU only, despite spanning two continents.
  • Any country code absent from the map produced priorContinent = undefined, which short-circuited the cross-continent check entirely, leaving the detector blind for those submissions.

Bug 3 — Only the most-recent prior submission was checked

// BEFORE (broken)
const prior = await prisma.kYCSubmission.findFirst({ ..., orderBy: { createdAt: 'desc' } });

Given a submission sequence A (Kenya, day 1) → B (UK, day 2) → C (Kenya, day 2, 2h after B), comparing only B → C shows a same-continent short-hop with no signal. But the B → C leg alone (London to Nairobi in 2 hours, ~3,400 km/h) is physically impossible and should fire. The old code happened to miss it because both countries were "different continents" but the 2-hour rule was the only gate — and the logic fired on the wrong pair. More generally, multi-hop impossible-travel patterns (A → B → A round trips, document re-use across geographies) were invisible to the single-pair check.

Bug 4 — Time delta used Date.now() instead of submission timestamps

// BEFORE (broken)
const hoursDiff = (Date.now() - new Date(prior.createdAt).getTime()) / (1000 * 60 * 60);

This measured time between the prior submission and when the assessment job ran, not between the prior submission and the current submission. If the FRAUD_DETECTION BullMQ worker was down for 6 hours and processed a backlog of submissions on restart, a genuinely suspicious 30-minute hop (prior at T, current at T+30min) would be evaluated as a ~6-hour gap and produce no signal.


Solution

1. Bundled country centroid dataset (data/country-centroids.json)

A static JSON file mapping 250 ISO 3166-1 alpha-2 country codes to { lat: number, lng: number } centroids is loaded once at module initialisation:

const COUNTRY_CENTROIDS: Record<string, { lat: number; lng: number }> =
  require('../../data/country-centroids.json');

No external API call is made at assessment time. The full centroid lookup is an in-process O(1) object property access. The file is 11 KB — well within the 500 KB repository budget constraint. Kosovo (XK), Taiwan (TW), and Palestine (PS) are all included.

2. Haversine great-circle distance (no npm dependency)

function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
  const R = 6371;
  const dLat = ((lat2 - lat1) * Math.PI) / 180;
  const dLng = ((lng2 - lng1) * Math.PI) / 180;
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos((lat1 * Math.PI) / 180) *
      Math.cos((lat2 * Math.PI) / 180) *
      Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

9 lines of trigonometry, zero dependencies. Implements the standard Haversine formula with Earth radius 6,371 km.

3. Physics-based checkCountryPair() helper

A new pure function checkCountryPair(fromCountry, toCountry, hoursDiff) encapsulates the detection logic for a single (from, to) hop:

  • Primary path (both countries have centroids): compute Haversine distance → implied speed → compare against config.kycFraud.geoMaxPlausibleSpeedKmh. Returns severity: 'high' if speed exceeds threshold.
  • Fallback path (one or both codes absent from centroid dataset): fall back to the continent-level check, but cap severity at 'medium' to reflect reduced precision.
  • Division-by-zero guard: sub-second hops are treated as 0.001h (~3.6 seconds) to avoid Infinity speeds.
  • Clock-skew guard: pairs where hoursDiff < 0 (out-of-order timestamps) are skipped silently.

4. Multi-hop sequence analysis

// AFTER — fetch last N prior submissions
const priors = await prisma.kYCSubmission.findMany({
  where: { userId: input.userId, id: { not: input.submissionId }, ... },
  orderBy: { createdAt: 'desc' },
  take: geoAnomalyLookback,   // default 5, configurable via GEO_ANOMALY_LOOKBACK
  include: { beneficiary: { select: { country: true } } },
});

The N most-recent prior submissions are fetched, reversed into chronological order, and appended with the current submission to form a full sequence. Every consecutive pair (sequence[i], sequence[i+1]) is checked. The loop returns on the first impossible-travel signal found (most-recent pair first), so the hottest anomaly is always surfaced.

5. Correct time delta via submittedAt

// FraudInput interface — new field
submittedAt?: Date;

// AFTER — inside checkGeoAnomaly()
const currentTs = input.submittedAt ?? new Date();
const hoursDiff = (to.createdAt.getTime() - from.createdAt.getTime()) / (1000 * 60 * 60);

The FraudInput interface gains an optional submittedAt?: Date field. When supplied by the caller (recommended), the time delta is computed entirely from stored timestamps and is immune to job-queue delays. When omitted, new Date() is used as a safe default (backward-compatible). Callers should always supply submittedAt from the KYC submission row's createdAt.

6. geoAnomalyLookback config + env var

// src/config/index.ts
geoAnomalyLookback: parseInt(process.env.GEO_ANOMALY_LOOKBACK || '5', 10),

// .env.example
GEO_ANOMALY_LOOKBACK=5

The number of prior submissions to examine is now configurable, defaulting to 5. Operators in high-velocity environments can lower this to reduce DB read load; operators investigating serial fraud can raise it.

7. getModelVersions() refactor (shadow scoring housekeeping)

As a related fix surfaced during the work, getActiveModelVersion() was refactored into getModelVersions(), which fetches both the active and shadow-candidate model versions in a single findMany call instead of two separate findFirst calls. The public getActiveModelVersion() wrapper is preserved for backward compatibility. A new applyPlattScaling() helper extracts the Platt sigmoid into a reusable, testable function.


Files Changed

File Change
data/country-centroids.json New — 250-entry ISO 3166-1 alpha-2 centroid dataset (11 KB)
src/services/kycFraud.service.ts Rewrites checkGeoAnomaly(), adds haversineKm(), checkCountryPair(), getModelVersions(), applyPlattScaling()
src/config/index.ts Adds geoAnomalyLookback config key
.env.example Documents GEO_ANOMALY_LOOKBACK=5
tests/unit/kycFraud.service.test.ts Adds 15 new tests covering all acceptance criteria; updates existing tests for findMany API

Test Coverage

All 45 tests pass. The new checkGeoAnomaly test suite is split into three describe blocks:

Core behaviour (updated existing tests)

  • Returns null when claimedCountry is not provided
  • Returns null when no prior submissions exist
  • Returns null when prior country matches current country (any time gap)
  • Returns high for intercontinental impossible speed (US→AU in 30 min, ~28,600 km/h)
  • Returns null for plausible slow travel (US→CA in 5 hours, ~300 km/h)

Acceptance criteria (new tests)

  • KE→UG in 30 min: ~510 km / 0.5h = ~1,020 km/h > 900 → severity: 'high'
  • KE→UG in 2 hours: ~510 km / 2h = ~255 km/h < 900 → no signal
  • KE→GB in 1 hour: ~6,800 km / 1h = ~6,800 km/h >> 900 → severity: 'high'
  • KE→KE (same country): no signal regardless of time gap
  • Multi-hop KE(day1)→GB(day2)→KE(day2+2h): GB→KE hop at ~3,400 km/h triggers 'high'
  • Unknown code ZZ: no centroid, no continent → graceful null
  • Kosovo XK: centroid present → Haversine fires correctly (XK→GB in 30 min → 'high')
  • Unknown code in both positions (XXXX): same-country short-circuit → null
  • Time delta uses submittedAt: prior fixed at 2020-01-01T12:00Z, submittedAt at +30min → fires 'high'; without submittedAt the 4-year Date.now() delta would produce no signal
  • submittedAt omitted: falls back to Date.now(), still correct when prior is genuinely 30 min ago

Performance

  • 100 consecutive checkGeoAnomaly() calls with varied country pairs complete in < 50ms (centroid lookup is pure in-process O(1))

Before / After: KE→UG Scenario

A Kenyan refugee family flees to Uganda and submits KYC 30 minutes after their last Kenya submission.

Before After
Detection method Continent string equality (KE=AF, UG=AF → same continent) Haversine: KE→UG = ~510 km / 0.5h = ~1,020 km/h
Signal fired? ❌ No (same continent, silent miss) ✅ Yes — severity: 'high' (speed > 900 km/h threshold)
Config respected? geoMaxPlausibleSpeedKmh defined but never used ✅ Threshold is config.kycFraud.geoMaxPlausibleSpeedKmh

A Kenyan family submits KYC in Uganda 2 hours after their Kenyan submission (plausible drive/flight).

Before After
Signal fired? ❌ No (same continent) ✅ No — ~255 km/h, well below 900 km/h threshold

Nairobi to London in 1 hour (fraudulent).

Before After
Signal fired? ✅ Yes (cross-continent < 2h) ✅ Yes — ~6,800 km/h >> 900 km/h, severity: 'high'

Configuration Reference

Env Var Config Key Default Description
KYC_FRAUD_GEO_MAX_SPEED_KMH config.kycFraud.geoMaxPlausibleSpeedKmh 900 Max implied travel speed (km/h) before flagging impossible travel. Commercial jet range is 600–900 km/h.
GEO_ANOMALY_LOOKBACK config.kycFraud.geoAnomalyLookback 5 Number of prior KYC submissions to include in the multi-hop sequence check.

Out of Scope

  • IP geolocation (claimedCountry from the beneficiary profile is used, not IP address)
  • Changing config.kycFraud.weights.geoAnomaly (still 20)
  • Modifying checkDocumentReuse, checkVelocity, or checkDeviceFingerprint

Checklist

  • data/country-centroids.json added (250 codes, 11 KB, < 500 KB limit)
  • Haversine implemented without npm package (9 lines)
  • config.kycFraud.geoMaxPlausibleSpeedKmh is the only threshold — no hardcoded 900 in kycFraud.service.ts
  • GEO_ANOMALY_LOOKBACK env var documented in .env.example
  • FraudSignal return type unchanged, 'geoAnomaly' signal name unchanged
  • All 45 existing + new unit tests pass
  • Performance test: 100 calls < 50ms
  • No external API calls at assessment time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants