File size: 7,024 Bytes
8e6c6e4
e11f6a9
8e6c6e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e11f6a9
8e6c6e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e11f6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6c6e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
// ═══════════════════════════════════════════════════════════════
// MEMEX β€” Data Transform Utilities
// ═══════════════════════════════════════════════════════════════

import { GraphNode, GraphEdge, EntityNodeType, EdgeType } from './types';

// ── Scenario β†’ Cytoscape Graph Elements ──────────────────────

interface ScenarioData {
  customer_profiles?: Record<string, Record<string, unknown>>;
  network_graph?: Record<string, { connections?: Array<Record<string, unknown>>; entity_name?: string }>;
  transactions?: Array<Record<string, unknown>>;
  watchlist_results?: Record<string, Record<string, unknown>>;
}

function inferNodeType(profile: Record<string, unknown>): EntityNodeType {
  const type = (profile.type as string || '').toLowerCase();
  const name = (profile.name as string || '').toLowerCase();
  const notes = (profile.notes as string || '').toLowerCase();

  if (type === 'individual' || profile.date_of_birth || profile.occupation || profile.nationality) return 'person';
  if (notes.includes('shell') || notes.includes('no employees') || notes.includes('no commercial')) return 'shell';
  if (type === 'business' || profile.business_type || profile.directors) return 'company';
  if (name.includes('account') || name.includes('acc-')) return 'account';
  return 'company';
}

function inferRisk(profile: Record<string, unknown>, watchlist?: Record<string, Record<string, unknown>>): number {
  let score = 30;
  const riskRating = (profile.risk_rating as string || '').toLowerCase();
  if (riskRating === 'high') score += 40;
  else if (riskRating === 'medium') score += 20;

  if (profile.pep_status) score += 25;
  
  const name = profile.name as string || '';
  if (watchlist && watchlist[name]?.hit) score += 20;

  const notes = (profile.notes as string || '').toLowerCase();
  if (notes.includes('shell') || notes.includes('no employees')) score += 15;
  if (notes.includes('shared') || notes.includes('nominee')) score += 10;

  return Math.min(score, 100);
}

export function scenarioToGraph(scenario: ScenarioData): { nodes: GraphNode[]; edges: GraphEdge[] } {
  const nodes: GraphNode[] = [];
  const edges: GraphEdge[] = [];
  const nodeIds = new Set<string>();

  const profiles = scenario.customer_profiles || {};
  const network = scenario.network_graph || {};
  const watchlist = scenario.watchlist_results || {};

  // Build nodes from profiles
  for (const [id, profile] of Object.entries(profiles)) {
    if (nodeIds.has(id)) continue;
    nodeIds.add(id);
    nodes.push({
      id,
      label: (profile.name as string) || id,
      type: inferNodeType(profile),
      risk: inferRisk(profile, watchlist),
      jurisdiction: (profile.nationality as string) || (profile.jurisdiction as string),
      flagged: !!(watchlist[(profile.name as string)]?.hit || watchlist[id]?.hit),
      pep: !!profile.pep_status,
      meta: profile,
    });
  }

  // Build edges from network graph
  const edgeIds = new Set<string>();
  for (const [entityId, node] of Object.entries(network)) {
    if (!node.connections) continue;

    // Ensure source node exists
    if (!nodeIds.has(entityId)) {
      nodeIds.add(entityId);
      nodes.push({
        id: entityId,
        label: entityId, // fallback
        type: entityId.startsWith('CUST') ? 'person' : 'company',
        risk: 40,
        flagged: false,
        pep: false,
      });
    }

    for (const conn of node.connections) {
      const targetId = (conn.entity_id as string) || (conn.entity as string) || '';
      if (!targetId) continue;

      const edgeId = `e-${entityId}-${targetId}`;
      const reverseId = `e-${targetId}-${entityId}`;
      if (edgeIds.has(edgeId) || edgeIds.has(reverseId)) continue;
      edgeIds.add(edgeId);

      // Ensure target node exists
      if (!nodeIds.has(targetId)) {
        nodeIds.add(targetId);
        const targetName = (conn.entity_name as string) || targetId;
        const isPep = !!(conn.pep || (conn.director as string || '').includes('PEP'));
        nodes.push({
          id: targetId,
          label: targetName,
          type: isPep ? 'person' : 'company',
          risk: isPep ? 80 : 40,
          flagged: isPep,
          pep: isPep,
        });
      }

      const rel = (conn.relationship as string || '').toLowerCase();
      let edgeType: EdgeType = 'association';
      if (rel.includes('wire') || rel.includes('transfer') || rel.includes('inbound') || rel.includes('outbound')) edgeType = 'transaction';
      else if (rel.includes('director') || rel.includes('owns') || rel.includes('owner')) edgeType = 'ownership';
      else if (rel.includes('shared') || rel.includes('address')) edgeType = 'suspicious';

      edges.push({
        id: edgeId,
        source: entityId,
        target: targetId,
        type: edgeType,
        label: conn.relationship as string,
        amount: conn.amount as number | undefined,
        suspicious: edgeType === 'suspicious' || !!(conn.note as string || '').includes('SHARED'),
      });
    }
  }

  // Build edges from transactions
  const txns = scenario.transactions || [];
  for (const txn of txns) {
    const from = (txn.from as string) || (txn.sender_id as string) || '';
    const to = (txn.to as string) || (txn.receiver_id as string) || '';
    if (!from || !to) continue;

    const edgeId = `txn-${txn.transaction_id}`;
    if (edgeIds.has(edgeId)) continue;
    edgeIds.add(edgeId);

    // Ensure nodes exist for external entities
    for (const [eid, ename] of [[from, from], [to, to]]) {
      if (!nodeIds.has(eid)) {
        nodeIds.add(eid);
        nodes.push({
          id: eid,
          label: ename,
          type: (ename as string).includes('ENT') ? 'company' : 'asset',
          risk: 20,
        });
      }
    }

    edges.push({
      id: edgeId,
      source: from,
      target: to,
      type: 'transaction',
      label: `$${formatCompact(txn.amount as number)}`,
      amount: txn.amount as number,
      suspicious: (txn.amount as number) > 100000,
    });
  }

  return { nodes, edges };
}

function formatCompact(n: number): string {
  if (!n) return '0';
  if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
  if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`;
  if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
  return String(n);
}

// ── Risk Color ───────────────────────────────────────────────
export function riskToColor(score: number): string {
  if (score >= 80) return '#E11D48';
  if (score >= 60) return '#EA580C';
  if (score >= 40) return '#EAB308';
  return '#22C55E';
}

export function nodeSize(risk: number): number {
  return 24 + (risk / 100) * 24;
}