Platform243
Fraud & Financial Crime Intelligence — Financial Services

Real-Time Fraud Ring Detection on a Neo4j Graph

All Case Studies
0ms
p99 scoring latency
0.0x
More rings surfaced
0%
Fewer false positives
Challenge

The fraud engine scored each transaction in isolation against a rules table. Individually every payment looked ordinary — under the limit, known device, established account — so mule networks moving value in short hops across dozens of accounts passed straight through. Analysts could reconstruct a ring afterwards in a spreadsheet, but it took days, and by then the funds were gone. The rules that did fire were blunt enough that genuine customers were being blocked.

Approach

We modelled accounts, devices, phone numbers, and payments as a property graph rather than rows. Entity resolution ran first, collapsing the duplicate customer identities that had been hiding the connections. Ring detection then became a traversal instead of a join chain: shared-identifier communities precomputed nightly, and a bounded read-time query scoring the live neighbourhood around each authorization.

Architecture

Core banking and wallet events land in Kafka. Flink handles enrichment and entity resolution, then upserts nodes and relationships into Neo4j. A scoring service queries the graph synchronously in the authorization path behind a hard timeout, falling back to rules-only if the graph is slow — so fraud tooling can never take payments down. Louvain community detection runs nightly to assign ring IDs, and investigators work cases in Neo4j Bloom against the same graph the scorer reads.

Implementation

The shape of the build: the model, the detection query, the read-time score, and the offline pass that keeps the read-time score cheap.

Graph model — constraints and indexesCypher
// One node per real-world entity; entity resolution runs upstream// in Flink so the graph never stores two nodes for one customer.CREATE CONSTRAINT account_id IF NOT EXISTSFOR (a:Account) REQUIRE a.id IS UNIQUE; CREATE CONSTRAINT device_id IF NOT EXISTSFOR (d:Device) REQUIRE d.id IS UNIQUE; // Transfers are relationships, not rows — traversal replaces the join chain.CREATE INDEX transfer_at IF NOT EXISTSFOR ()-[t:TRANSFER]-() ON (t.at); CREATE INDEX account_flagged IF NOT EXISTSFOR (a:Account) ON (a.flagged);

Uniqueness constraints are what make entity resolution meaningful: one node per real customer, or the connections you are looking for stay hidden behind duplicates.

Ring detection — shared device, bounded traversalCypher
// Accounts reachable from a flagged account through a shared device,// then up to three transfer hops inside the last seven days.MATCH (flagged:Account {id: $accountId})      -[:USED_DEVICE]->(:Device)<-[:USED_DEVICE]-(peer:Account)WHERE peer <> flaggedMATCH path = (peer)-[:TRANSFER*1..3]->(dest:Account)WHERE all(t IN relationships(path)          WHERE t.at > datetime() - duration('P7D'))RETURN DISTINCT dest.id      AS account,                length(path) AS hops,                peer.id      AS viaORDER BY hopsLIMIT 50;

The same question in relational SQL needs six joins and degrades with every hop. Here the hop count is a parameter, and the time filter keeps the traversal bounded.

Risk score in the authorization pathCypher
// Bounded and index-anchored: this runs inside the authorization path,// behind a hard timeout with a rules-only fallback.MATCH (a:Account {id: $accountId})OPTIONAL MATCH (a)-[:USED_DEVICE|SHARES_MSISDN]-()               -[:USED_DEVICE|SHARES_MSISDN]-(peer:Account)WHERE peer.flaggedWITH a, count(DISTINCT peer) AS flaggedPeersOPTIONAL MATCH (a)-[t:TRANSFER]->(d:Account)WHERE d.ringId = a.ringId  AND t.at > datetime() - duration('PT24H')RETURN flaggedPeers * 30     + count(t) * 10     + coalesce(a.ringRisk, 0) AS score;

Anchored on a unique-constraint lookup and deliberately shallow. Anything expensive — community structure, historical risk — is precomputed and read as a property.

Nightly community detection with Graph Data ScienceCypher
// Nightly: project the shared-identifier network and label the rings,// so the read-time query above is a property lookup, not a computation.CALL gds.graph.project(  'fraud-net',  ['Account', 'Device'],  { USED_DEVICE: { orientation: 'UNDIRECTED' } }); CALL gds.louvain.write('fraud-net', {  writeProperty: 'ringId',  maxLevels: 10})YIELD communityCount, modularity;

Louvain labels each account with a ring ID offline. That moves the expensive computation out of the payment path and turns it into a property lookup.

Results

The graph surfaced 4.2x more confirmed rings than the rules engine it replaced, at a p99 scoring latency of 38ms inside the authorization window. False positives fell 61%, and investigation time per case dropped from days to hours because analysts could see the network instead of a list of alerts.

Benefits Delivered
  • Rings caught at authorization time, not reconstructed days later
  • Investigators see the network, not a queue of disconnected alerts
  • Fewer false positives, so genuine customers stop being blocked
  • One graph serves both real-time scoring and offline investigation
  • New fraud patterns ship as Cypher queries, not schema migrations
  • Hard timeout and rules-only fallback keep the payment path safe
Technologies
Neo4jCypherGraph Data ScienceKafkaFlinkReal-Time