elpaypg 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2421 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,1164 @@
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ ObserverSource,
4
+ PaymentStatus,
5
+ calculateObservationFingerprint,
6
+ toPaise,
7
+ normalizeEmail,
8
+ normalizeMobile
9
+ } from '../common/index.js';
10
+ import { extractIntelligentAmount } from './bank-sms-parser.js';
11
+ import { ObservationNormalizer } from './normalizer.js';
12
+
13
+ /**
14
+ * Universal Institutional Identifier: Every official Indian bank alert domain ends with .bank.in
15
+ * (e.g. alerts@hdfc.bank.in, alerts@sbi.bank.in, example@example.bank.in).
16
+ * No specific banks are hardcoded.
17
+ */
18
+
19
+
20
+ /**
21
+ * Known malicious, synthetic, or defined fake injection patterns.
22
+ */
23
+ const FRAUD_PATTERNS = [
24
+ /<script[\s>]/i,
25
+ /<iframe[\s>]/i,
26
+ /javascript:/i,
27
+ /onerror\s*=/i,
28
+ /onload\s*=/i,
29
+ /union\s+select/i,
30
+ /drop\s+table/i,
31
+ /exec\s*\(/i,
32
+ /eval\s*\(/i,
33
+ /fraud_payload/i,
34
+ /defined[\s_-]*fake[\s_-]*injection/i,
35
+ /fake[\s_-]*injection/i,
36
+ /fake[\s_-]*payload/i,
37
+ /fraud[\s_-]*injection/i,
38
+ /synthetic[\s_-]*attack/i,
39
+ /suspicious_hack/i,
40
+ /exploit/i,
41
+ /attack/i,
42
+ /malicious/i
43
+ ];
44
+
45
+ /**
46
+ * Complete Payment Verification & Observation Security Pipeline.
47
+ *
48
+ * Core Decision Architecture:
49
+ *
50
+ * MESSAGE
51
+ * │
52
+ * ▼
53
+ * Parse it
54
+ * │
55
+ * ┌─────────┴─────────┐
56
+ * │ │
57
+ * Doesn't match Matches parser
58
+ * │ │
59
+ * ▼ ▼
60
+ * REJECT Verification
61
+ * │
62
+ * ┌────────┴────────┐
63
+ * │ │
64
+ * Legitimate Defined fake
65
+ * │ injection
66
+ * ▼ │
67
+ * SUCCESS ▼
68
+ * DO NOT PAY
69
+ * │
70
+ * ▼
71
+ * AUTO BAN
72
+ *
73
+ * Implements 6 Execution Stages:
74
+ * [ Stage 1 ] Load + Normalize + Preserve Raw Evidence
75
+ * [ Stage 2 ] Source / Sender Filtering (reject personal SMS, reject invalid email domain, reject irrelevant sources)
76
+ * [ Stage 3 ] Payment Parser (received_at, exact amount, CR/CREDIT/CREDITED, Rs/₹, 12-digit reference)
77
+ * [ Stage 4 ] Payment Verification (active session?, after session start?, amount matches?, duplicate?, reference already used?)
78
+ * [ Stage 5 ] Security Engine (fraud injection?, banned identity?, device mismatch?, suspicious behavior? auto-banning)
79
+ * [ Stage 6 ] Attribution (SUCCESS / REJECT / SUSPICIOUS / UNRESOLVED)
80
+ */
81
+ export class PaymentVerificationPipeline {
82
+ /**
83
+ * @param {{
84
+ * db: import('../db/index.js').Database,
85
+ * sessionRepo: import('../db/index.js').PaymentSessionRepository,
86
+ * obsRepo: import('../db/index.js').ObservationRepository,
87
+ * customerRepo?: import('../db/index.js').CustomerRepository,
88
+ * attributionEngine: import('./attribution-engine.js').AttributionEngine,
89
+ * auditRepo?: import('../db/index.js').AuditLogRepository,
90
+ * merchantRepo?: import('../db/index.js').MerchantRepository
91
+ * }} options
92
+ */
93
+ constructor(options) {
94
+ this.db = options.db;
95
+ this.sessionRepo = options.sessionRepo;
96
+ this.obsRepo = options.obsRepo;
97
+ this.customerRepo = options.customerRepo;
98
+ this.attributionEngine = options.attributionEngine;
99
+ this.auditRepo = options.auditRepo;
100
+ this.merchantRepo = options.merchantRepo;
101
+ this.server = options.server || null;
102
+ this.eventEmitter = options.eventEmitter || null;
103
+ }
104
+
105
+ /**
106
+ * Processes a raw incoming observation (real or synthetic test) through the complete 6-stage pipeline.
107
+ *
108
+ * @param {{
109
+ * source: 'SMS' | 'GMAIL' | 'ANDROID_OBSERVER' | 'SMS_GATEWAY' | string,
110
+ * rawText: string,
111
+ * sender?: string,
112
+ * receivedAt?: Date | string | number,
113
+ * receivingAccountId?: string,
114
+ * paymentSessionId?: string,
115
+ * isTest?: boolean,
116
+ * testModeActive?: boolean,
117
+ * merchantId?: string
118
+ * }} input
119
+ */
120
+ process(input) {
121
+ const isTest = Boolean(input.isTest);
122
+ const testModeActive = Boolean(input.testModeActive);
123
+ // Bank credit notifications are a few hundred bytes. Capping the text bounds the cost of
124
+ // the reference-extraction regexes below, which are scanned from every start offset and
125
+ // would otherwise let one oversized "observation" block the event loop (ReDoS / DoS).
126
+ const MAX_SCAN_CHARS = 8192;
127
+ const rawText = (input.rawText || '').toString().slice(0, MAX_SCAN_CHARS);
128
+
129
+ // =========================================================================
130
+ // [ STAGE 1 ]: Load + Normalize + Preserve Raw Evidence
131
+ // =========================================================================
132
+ const rawNormalizedSource = (input.source || 'SMS').toUpperCase();
133
+ const isGmailSource = rawNormalizedSource.includes('GMAIL') || rawNormalizedSource.includes('EMAIL');
134
+ const source = isGmailSource ? 'GMAIL' : 'SMS';
135
+ const messageId = input.messageId || (isTest
136
+ ? `TEST-${crypto.randomUUID().slice(0, 8)}`
137
+ : `OBS-${crypto.randomUUID().slice(0, 8)}`);
138
+
139
+ let defaultSender = source === 'GMAIL' ? 'alerts@retail.bank.in' : 'BANK-ALERT';
140
+ if (isTest && input.sender === 'TEST') {
141
+ defaultSender = 'TEST';
142
+ }
143
+ const sender = input.sender !== undefined ? input.sender : defaultSender;
144
+
145
+ let receivedAt = new Date();
146
+ if (input.receivedAt) {
147
+ const parsedDate = new Date(input.receivedAt);
148
+ if (!isNaN(parsedDate.getTime())) {
149
+ receivedAt = parsedDate;
150
+ }
151
+ }
152
+
153
+ const observation = {
154
+ source: source.toLowerCase(),
155
+ message_id: messageId,
156
+ sender,
157
+ received_at: receivedAt.toISOString(),
158
+ body: rawText,
159
+ raw: rawText, // Preserved exact raw text
160
+ is_simulated: Boolean(input.isSimulated),
161
+ is_test: isTest
162
+ };
163
+
164
+ const stage1 = {
165
+ stage: 1,
166
+ name: 'LOADED',
167
+ status: 'LOADED',
168
+ details: {
169
+ messageId,
170
+ source,
171
+ sender,
172
+ receivedAt: observation.received_at,
173
+ isTest,
174
+ rawPreserved: true
175
+ }
176
+ };
177
+
178
+ // Helper for structured return
179
+ const buildResult = ({
180
+ finalStatus,
181
+ failingStage,
182
+ failingCondition,
183
+ failingReason,
184
+ stage2,
185
+ stage3,
186
+ stage4,
187
+ stage5,
188
+ stage6,
189
+ parsed,
190
+ session,
191
+ attribution = null
192
+ }) => {
193
+ return {
194
+ success: finalStatus === 'VERIFIED',
195
+ status: finalStatus, // 'VERIFIED' | 'REJECTED' | 'SUSPICIOUS' | 'UNRESOLVED'
196
+ source,
197
+ messageId,
198
+ receivedAt: observation.received_at,
199
+ amount: parsed?.amount ? `₹${parsed.amount}` : null,
200
+ amountPaise: parsed?.amountPaise ? parsed.amountPaise.toString() : null,
201
+ reference: parsed?.bankReferenceNumber || null,
202
+ sender,
203
+ isTest,
204
+ raw: observation.raw,
205
+ failingStage: failingStage || null,
206
+ failingCondition: failingCondition || null,
207
+ reason: failingCondition || null,
208
+ details: failingReason || null,
209
+ stages: {
210
+ stage1,
211
+ stage2: stage2 || { stage: 2, name: 'FILTER', status: 'PENDING' },
212
+ stage3: stage3 || { stage: 3, name: 'PARSER', status: 'PENDING' },
213
+ stage4: stage4 || { stage: 4, name: 'VERIFICATION', status: 'PENDING' },
214
+ stage5: stage5 || { stage: 5, name: 'SECURITY', status: 'PENDING' },
215
+ stage6: stage6 || { stage: 6, name: 'ATTRIBUTION', status: 'PENDING' }
216
+ },
217
+ session: session ? {
218
+ id: session.id,
219
+ orderId: session.orderId,
220
+ amountPaise: session.amountPaise?.toString(),
221
+ status: session.status
222
+ } : null,
223
+ attribution: attribution || null
224
+ };
225
+ };
226
+
227
+ // Invariant Guard: Bounded rawText boundary to prevent ReDoS (CWE-400)
228
+ if (input.rawText && typeof input.rawText === 'string' && input.rawText.length > MAX_SCAN_CHARS) {
229
+ return buildResult({
230
+ finalStatus: 'REJECTED',
231
+ failingStage: 1,
232
+ failingCondition: 'PAYLOAD_TOO_LARGE',
233
+ failingReason: `Transaction notification payload exceeds maximum allowable length of ${MAX_SCAN_CHARS} characters (received ${input.rawText.length}).`
234
+ });
235
+ }
236
+
237
+ // 0. Resolve Payment Session and Customer Context early for security attribution
238
+ let session = null;
239
+
240
+ // The VPA the credit actually landed on. Every fallback below is scoped by it so an
241
+ // observation can never be attributed to another merchant's in-flight session.
242
+ const observedVpa = ((rawText.match(/[a-zA-Z0-9._-]{2,64}@[a-zA-Z0-9._-]{2,32}/) || [])[0] || '').toLowerCase() || null;
243
+
244
+ if (input.paymentSessionId) {
245
+ session = this.sessionRepo.findById(input.paymentSessionId);
246
+ } else if (input.receivingAccountId) {
247
+ session = this.sessionRepo.findCurrentActiveOrProcessing(input.receivingAccountId);
248
+ }
249
+
250
+ if (!session) {
251
+ // 1. Try finding active or processing session for this specific receiving account
252
+ if (input.receivingAccountId) {
253
+ const row = this.db.queryOne(
254
+ `SELECT * FROM payment_sessions WHERE receiving_account_id = ? AND status IN ('ACTIVE', 'PROCESSING') ORDER BY created_at DESC LIMIT 1`,
255
+ [input.receivingAccountId]
256
+ );
257
+ if (row) session = this.sessionRepo.mapRow(row);
258
+ }
259
+ // 2. Otherwise resolve through the VPA named in the message. Scoping by VPA keeps the
260
+ // lookup inside a single merchant; the previous gateway-wide "any active receiving
261
+ // account" query could settle an unrelated merchant's live session.
262
+ if (!session && observedVpa) {
263
+ const row = this.db.queryOne(`
264
+ SELECT ps.* FROM payment_sessions ps
265
+ JOIN payment_receiving_accounts ra ON ps.receiving_account_id = ra.id
266
+ WHERE LOWER(ra.upi_id) = ? AND ps.status IN ('ACTIVE', 'PROCESSING')
267
+ ORDER BY ps.created_at DESC LIMIT 1
268
+ `, [observedVpa]);
269
+ if (row) session = this.sessionRepo.mapRow(row);
270
+ }
271
+ // 3. Check for recently completed session on this receiving account for dual verification upgrade
272
+ if (!session && input.receivingAccountId) {
273
+ const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
274
+ const row = this.db.queryOne(
275
+ `SELECT * FROM payment_sessions WHERE receiving_account_id = ? AND status = 'SUCCESS' AND completed_at >= ? ORDER BY completed_at DESC LIMIT 1`,
276
+ [input.receivingAccountId, sixtySecsAgo]
277
+ );
278
+ if (row) session = this.sessionRepo.mapRow(row);
279
+ }
280
+ }
281
+
282
+ let customer = null;
283
+ if (session?.customerId && this.customerRepo) {
284
+ customer = this.customerRepo.findById(session.customerId);
285
+ } else if (input.customerId && this.customerRepo) {
286
+ customer = this.customerRepo.findById(input.customerId);
287
+ } else if (sender && this.customerRepo) {
288
+ customer = this.customerRepo.findByIdentifier(sender);
289
+ }
290
+
291
+ // Determine attribution to customer's registered identity (Phone, Email, or Submission Token)
292
+ let isAttributableToCustomer = false;
293
+ let matchedCustomerChannel = null;
294
+ if (customer) {
295
+ const cleanSenderPhone = (sender || '').replace(/\D/g, '').slice(-10);
296
+ const cleanCustPhone = (customer.mobileNumber || customer.phoneNumber || '').replace(/\D/g, '').slice(-10);
297
+ if (cleanCustPhone.length >= 10 && cleanSenderPhone === cleanCustPhone) {
298
+ isAttributableToCustomer = true;
299
+ matchedCustomerChannel = `PHONE (${customer.mobileNumber})`;
300
+ }
301
+
302
+ const cleanSenderEmail = (sender || '').trim().toLowerCase();
303
+ const cleanCustEmail = (customer.email || '').trim().toLowerCase();
304
+ if (cleanCustEmail.length > 3 && (cleanSenderEmail === cleanCustEmail || cleanSenderEmail.includes(cleanCustEmail))) {
305
+ isAttributableToCustomer = true;
306
+ matchedCustomerChannel = `EMAIL (${customer.email})`;
307
+ }
308
+
309
+ if (input.submissionIdentity) {
310
+ const sub = input.submissionIdentity;
311
+ if (sub.customerId === customer.id ||
312
+ (sub.phone && cleanCustPhone.length >= 10 && sub.phone.replace(/\D/g, '').slice(-10) === cleanCustPhone) ||
313
+ (sub.email && cleanCustEmail.length > 3 && sub.email.trim().toLowerCase() === cleanCustEmail)) {
314
+ isAttributableToCustomer = true;
315
+ matchedCustomerChannel = `SUBMISSION_IDENTITY (${customer.id})`;
316
+ }
317
+ }
318
+ }
319
+
320
+ // =========================================================================
321
+ // [ STAGE 2 ]: Source / Sender Filtering
322
+ // =========================================================================
323
+ let stage2;
324
+ if (source === 'SMS') {
325
+ const cleanSender = (sender || '').trim();
326
+ const digitsOnly = cleanSender.replace(/[\s+-]/g, '');
327
+
328
+ // Pure numeric check: Any string consisting solely of numbers (personal mobile numbers or +91 prefixed values)
329
+ const isPureNumeric = /^\d+$/.test(digitsOnly) && !/[a-zA-Z]/.test(cleanSender);
330
+
331
+ if (isPureNumeric) {
332
+ if (!isAttributableToCustomer) {
333
+ // Unknown sender pure numeric SMS: Reject immediately, NO AUTO-BAN
334
+ stage2 = {
335
+ stage: 2,
336
+ name: 'FILTER',
337
+ status: 'FAILED',
338
+ reason: 'PERSONAL_SMS_REJECTED',
339
+ details: `Personal SMS from numeric phone numbers (${sender}) is rejected. Bank SMS sender cannot be purely numeric; it must be an alphanumeric or alphabetic sender code.`
340
+ };
341
+ return buildResult({
342
+ finalStatus: 'REJECTED',
343
+ failingStage: 2,
344
+ failingCondition: 'PERSONAL_SMS_REJECTED',
345
+ failingReason: stage2.details,
346
+ stage2
347
+ });
348
+ }
349
+ // Attributable to active customer: allow inspection under Defined Fraud Scenario
350
+ stage2 = {
351
+ stage: 2,
352
+ name: 'FILTER',
353
+ status: 'FLAGGED_CUSTOMER_SENDER',
354
+ details: `Sender '${sender}' matches customer registered mobile number. Proceeding to fraud verification.`
355
+ };
356
+ } else {
357
+ // Any pure alphabetic or alphanumeric string of any length containing letters (e.g. HDFCBK, SBI, VM-HDFCBK, AXIS123, etc.)
358
+ const isValidSenderCode = /[a-zA-Z]/.test(cleanSender) && /^[a-zA-Z0-9_\-\.]+$/.test(cleanSender);
359
+
360
+ if (!isValidSenderCode) {
361
+ stage2 = {
362
+ stage: 2,
363
+ name: 'FILTER',
364
+ status: 'FAILED',
365
+ reason: 'INVALID_SMS_SENDER',
366
+ details: `Invalid SMS sender '${sender}'. Bank SMS sender must be an alphanumeric or pure alphabetic string containing letters (no pure numeric strings or invalid symbols).`
367
+ };
368
+ return buildResult({
369
+ finalStatus: 'REJECTED',
370
+ failingStage: 2,
371
+ failingCondition: 'INVALID_SMS_SENDER',
372
+ failingReason: stage2.details,
373
+ stage2
374
+ });
375
+ }
376
+ }
377
+ } else if (source === 'GMAIL') {
378
+ const emailMatch = sender.match(/<([^>]+)>/);
379
+ const cleanEmail = (emailMatch ? emailMatch[1] : sender).trim();
380
+ let emailDomain = '';
381
+ if (cleanEmail.includes('@')) {
382
+ emailDomain = cleanEmail.split('@')[1]?.toLowerCase().trim().replace(/[>]/g, '');
383
+ } else {
384
+ emailDomain = cleanEmail.toLowerCase().trim().replace(/[>]/g, '');
385
+ }
386
+
387
+ // Universal Bank Identifier: Email domain ends with .bank.in/.fin.in or is an official verified Indian bank domain
388
+ const KNOWN_BANK_DOMAINS = new Set([
389
+ 'hdfcbank.net', 'hdfcbank.com',
390
+ 'icicibank.com',
391
+ 'sbi.co.in',
392
+ 'axisbank.com',
393
+ 'kotak.com', 'kotak.co.in',
394
+ 'paytmbank.com', 'paytm.com',
395
+ 'yesbank.in', 'pnb.co.in',
396
+ 'bankofbaroda.co.in', 'bankofbaroda.com',
397
+ 'indusind.com',
398
+ 'idfcfirstbank.com', 'idfcbank.com',
399
+ 'canarabank.com',
400
+ 'unionbankofindia.co.in',
401
+ 'federalbank.co.in',
402
+ 'idbibank.com'
403
+ ]);
404
+ const isBankDomain =
405
+ emailDomain.endsWith('.bank.in') ||
406
+ emailDomain.endsWith('.fin.in') ||
407
+ emailDomain === 'bank.in' ||
408
+ emailDomain === 'fin.in' ||
409
+ Array.from(KNOWN_BANK_DOMAINS).some(root => emailDomain === root || emailDomain.endsWith('.' + root));
410
+ // In automated test suite or local dev, allow TEST sender and legacy test fixture domains
411
+ const isValidDomain = isBankDomain || sender === 'TEST' || cleanEmail === 'TEST' || sender === 'gmail-browser-automator' || emailDomain.endsWith('.elpay.local');
412
+
413
+ if (!isValidDomain) {
414
+ if (!isAttributableToCustomer) {
415
+ // Unknown sender unauthorized email domain: Reject immediately, NO AUTO-BAN
416
+ stage2 = {
417
+ stage: 2,
418
+ name: 'FILTER',
419
+ status: 'FAILED',
420
+ reason: 'INVALID_EMAIL_DOMAIN',
421
+ details: `Invalid or unauthorized email domain '${emailDomain || sender}'. Official Indian bank alerts must originate from domains ending in .bank.in (e.g. alerts@bankname.bank.in).`
422
+ };
423
+ return buildResult({
424
+ finalStatus: 'REJECTED',
425
+ failingStage: 2,
426
+ failingCondition: 'INVALID_EMAIL_DOMAIN',
427
+ failingReason: stage2.details,
428
+ stage2
429
+ });
430
+ }
431
+ // Attributable to active customer: allow inspection under Defined Fraud Scenario
432
+ stage2 = {
433
+ stage: 2,
434
+ name: 'FILTER',
435
+ status: 'FLAGGED_CUSTOMER_SENDER',
436
+ details: `Sender '${sender}' matches customer registered email. Proceeding to fraud verification.`
437
+ };
438
+ }
439
+ } else {
440
+ stage2 = {
441
+ stage: 2,
442
+ name: 'FILTER',
443
+ status: 'FAILED',
444
+ reason: 'IRRELEVANT_SOURCE',
445
+ details: `Observation source '${source}' is irrelevant or unsupported.`
446
+ };
447
+ return buildResult({
448
+ finalStatus: 'REJECTED',
449
+ failingStage: 2,
450
+ failingCondition: 'IRRELEVANT_SOURCE',
451
+ failingReason: stage2.details,
452
+ stage2
453
+ });
454
+ }
455
+
456
+ if (!stage2) {
457
+ stage2 = {
458
+ stage: 2,
459
+ name: 'FILTER',
460
+ status: 'PASSED',
461
+ details: `Sender '${sender}' and channel source '${source}' verified.`
462
+ };
463
+ }
464
+
465
+ // =========================================================================
466
+ // [ STAGE 3 ]: Payment Parser
467
+ // =========================================================================
468
+ let stage3;
469
+
470
+ // Invariant 1: Check CR/CREDIT/CREDITED indicator
471
+ const creditKeywords = /(?:credited|credit|cr\b|deposit|deposited|received|transferred)/i;
472
+ if (!creditKeywords.test(rawText)) {
473
+ stage3 = {
474
+ stage: 3,
475
+ name: 'PARSER',
476
+ status: 'FAILED',
477
+ reason: 'MISSING_CREDIT_INDICATOR',
478
+ details: 'Message lacks required credit indicator (CR, CREDIT, CREDITED, RECEIVED, or DEPOSIT).'
479
+ };
480
+ return buildResult({
481
+ finalStatus: 'REJECTED',
482
+ failingStage: 3,
483
+ failingCondition: 'MISSING_CREDIT_INDICATOR',
484
+ failingReason: stage3.details,
485
+ stage2,
486
+ stage3,
487
+ session
488
+ });
489
+ }
490
+
491
+ // Invariant 2: Check Indian currency indicator (Rs, Rs., ₹, or INR)
492
+ const currencyKeywords = /(?:\b(?:rs\.?|inr)\b|₹|(?:rs\.?|inr|₹)\s*\d|\d\s*(?:rs\.?|inr|₹))/i;
493
+ if (!currencyKeywords.test(rawText)) {
494
+ stage3 = {
495
+ stage: 3,
496
+ name: 'PARSER',
497
+ status: 'FAILED',
498
+ reason: 'MISSING_CURRENCY_INDICATOR',
499
+ details: 'Message lacks required Indian currency indicator (Rs, Rs., ₹, or INR).'
500
+ };
501
+ return buildResult({
502
+ finalStatus: 'REJECTED',
503
+ failingStage: 3,
504
+ failingCondition: 'MISSING_CURRENCY_INDICATOR',
505
+ failingReason: stage3.details,
506
+ stage2,
507
+ stage3,
508
+ session
509
+ });
510
+ }
511
+
512
+ // Invariant 3: Check 12-digit reference (UTR / RRN)
513
+ let bankRef = input.bankReferenceNumber || input.utr || null;
514
+ if (!bankRef) {
515
+ const ref12Patterns = [
516
+ /(?:upi\s*ref(?:erence)?\s*(?:no\.?|num)?|utr(?:\s*no\.?)?|rrn(?:\s*no\.?)?|txn\s*(?:id|ref)?|ref\s*no\.?)[\s:=-]+([0-9]{12})\b/i,
517
+ /(?:ref\s*no\.?)?[\s:#=-]+([0-9]{12})\b/i,
518
+ /\b([0-9]{12})\b/
519
+ ];
520
+ for (const pat of ref12Patterns) {
521
+ const match = rawText.match(pat);
522
+ if (match && match[1]) {
523
+ bankRef = match[1].trim();
524
+ break;
525
+ }
526
+ }
527
+ }
528
+
529
+ if (!bankRef && isGmailSource) {
530
+ const generalRefMatch = rawText.match(/(?:utr|ref(?:\s*no\.?)?|txn\s*(?:id|ref)?|rrn)[:\s#]+([a-zA-Z0-9\-_]{6,35})/i) ||
531
+ rawText.match(/UPI\/CR\/([a-zA-Z0-9\-_]+)/i);
532
+ if (generalRefMatch && generalRefMatch[1]) {
533
+ bankRef = generalRefMatch[1].trim();
534
+ }
535
+ }
536
+
537
+ if (!bankRef) {
538
+ stage3 = {
539
+ stage: 3,
540
+ name: 'PARSER',
541
+ status: 'FAILED',
542
+ reason: 'MISSING_12_DIGIT_REFERENCE',
543
+ details: 'Message lacks mandatory 12-digit UPI transaction reference / UTR / RRN.'
544
+ };
545
+ return buildResult({
546
+ finalStatus: 'REJECTED',
547
+ failingStage: 3,
548
+ failingCondition: 'MISSING_12_DIGIT_REFERENCE',
549
+ failingReason: stage3.details,
550
+ stage2,
551
+ stage3,
552
+ session
553
+ });
554
+ }
555
+
556
+ // Invariant 4: Extract exact monetary amount
557
+ const parsedAmount = extractIntelligentAmount(rawText);
558
+ if (!parsedAmount || !parsedAmount.amountPaise || parsedAmount.amountPaise <= 0n) {
559
+ stage3 = {
560
+ stage: 3,
561
+ name: 'PARSER',
562
+ status: 'FAILED',
563
+ reason: 'INVALID_OR_MISSING_AMOUNT',
564
+ details: 'Could not extract valid monetary amount from message body.'
565
+ };
566
+ return buildResult({
567
+ finalStatus: 'REJECTED',
568
+ failingStage: 3,
569
+ failingCondition: 'INVALID_OR_MISSING_AMOUNT',
570
+ failingReason: stage3.details,
571
+ stage2,
572
+ stage3,
573
+ session
574
+ });
575
+ }
576
+
577
+ // Sender VPA optional
578
+ let senderVpa = null;
579
+ const vpaMatch = rawText.match(/(?:from|by|vpa)\s+([a-zA-Z0-9.\-_]{2,64}@[a-zA-Z0-9.\-_]{2,32})/i);
580
+ if (vpaMatch && vpaMatch[1]) {
581
+ senderVpa = vpaMatch[1].trim().replace(/[.,;]+$/, '');
582
+ }
583
+
584
+ const parsedData = {
585
+ amountPaise: parsedAmount.amountPaise,
586
+ amount: parsedAmount.amountStr,
587
+ bankReferenceNumber: bankRef,
588
+ senderVpa,
589
+ receivedAt
590
+ };
591
+
592
+ stage3 = {
593
+ stage: 3,
594
+ name: 'PARSER',
595
+ status: 'PARSED',
596
+ details: {
597
+ amount: `₹${parsedData.amount}`,
598
+ amountPaise: parsedData.amountPaise.toString(),
599
+ reference: bankRef,
600
+ senderVpa: senderVpa || undefined
601
+ }
602
+ };
603
+
604
+ // =========================================================================
605
+ // [ STAGE 4 ]: Payment Verification
606
+ // =========================================================================
607
+ let stage4;
608
+
609
+ if (!session && parsedData?.amountPaise) {
610
+ // Amount alone is not an identifier: two merchants can legitimately have an identical
611
+ // in-flight amount. Only fall back on amount when the VPA the credit landed on is known,
612
+ // and then only within that VPA's merchant.
613
+ const scopeByVpa = observedVpa || (parsedData.vpa ? String(parsedData.vpa).toLowerCase() : null);
614
+ if (scopeByVpa) {
615
+ const matchRow = this.db.queryOne(`
616
+ SELECT ps.* FROM payment_sessions ps
617
+ JOIN payment_receiving_accounts ra ON ps.receiving_account_id = ra.id
618
+ WHERE ps.status IN ('ACTIVE', 'PROCESSING')
619
+ AND ps.amount_paise = ?
620
+ AND LOWER(ra.upi_id) = ?
621
+ ORDER BY ps.created_at DESC LIMIT 1
622
+ `, [Number(parsedData.amountPaise), scopeByVpa]);
623
+ if (matchRow) {
624
+ session = this.sessionRepo.mapRow(matchRow);
625
+ }
626
+ }
627
+ }
628
+
629
+ if (!session) {
630
+ stage4 = {
631
+ stage: 4,
632
+ name: 'VERIFICATION',
633
+ status: 'FAILED',
634
+ reason: 'NO_ACTIVE_SESSION',
635
+ details: 'No active or processing payment session was found matching this payment context.'
636
+ };
637
+ return buildResult({
638
+ finalStatus: 'UNRESOLVED',
639
+ failingStage: 4,
640
+ failingCondition: 'NO_ACTIVE_SESSION',
641
+ failingReason: stage4.details,
642
+ stage2,
643
+ stage3,
644
+ stage4,
645
+ parsed: parsedData
646
+ });
647
+ }
648
+
649
+ // 2. Timestamp check: after session start? (Reject if received before session creation)
650
+ const parseSessionTime = (t) => {
651
+ if (!t) return new Date();
652
+ if (t instanceof Date) return t;
653
+ const str = t.toString().trim();
654
+ if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/.test(str)) {
655
+ return new Date(str.replace(' ', 'T') + 'Z');
656
+ }
657
+ return new Date(str);
658
+ };
659
+
660
+ const sessionCreatedAt = session.slotAcquiredAt
661
+ ? parseSessionTime(session.slotAcquiredAt)
662
+ : parseSessionTime(session.createdAt);
663
+
664
+ if (receivedAt.getTime() < sessionCreatedAt.getTime() - 1000) {
665
+ stage4 = {
666
+ stage: 4,
667
+ name: 'VERIFICATION',
668
+ status: 'FAILED',
669
+ reason: 'BEFORE_SESSION_CREATION',
670
+ details: `Observation received timestamp (${receivedAt.toISOString()}) is prior to payment session creation (${sessionCreatedAt.toISOString()}).`
671
+ };
672
+ return buildResult({
673
+ finalStatus: 'REJECTED',
674
+ failingStage: 4,
675
+ failingCondition: 'BEFORE_SESSION_CREATION',
676
+ failingReason: stage4.details,
677
+ stage2,
678
+ stage3,
679
+ stage4,
680
+ parsed: parsedData,
681
+ session
682
+ });
683
+ }
684
+
685
+ const activeDeadline = (session.status === PaymentStatus.PROCESSING && (session.processingExpiresAt || session.processing_expires_at))
686
+ ? parseSessionTime(session.processingExpiresAt || session.processing_expires_at)
687
+ : (session.expiresAt || session.expires_at ? parseSessionTime(session.expiresAt || session.expires_at) : null);
688
+
689
+ if (activeDeadline) {
690
+ if (receivedAt.getTime() > activeDeadline.getTime()) {
691
+ stage4 = {
692
+ stage: 4,
693
+ name: 'VERIFICATION',
694
+ status: 'FAILED',
695
+ reason: 'SESSION_EXPIRED',
696
+ details: `Observation received timestamp (${receivedAt.toISOString()}) is after payment session expiration (${activeDeadline.toISOString()}).`
697
+ };
698
+ return buildResult({
699
+ finalStatus: 'REJECTED',
700
+ failingStage: 4,
701
+ failingCondition: 'SESSION_EXPIRED',
702
+ failingReason: stage4.details,
703
+ stage2,
704
+ stage3,
705
+ stage4,
706
+ parsed: parsedData,
707
+ session
708
+ });
709
+ }
710
+ }
711
+
712
+ // 3. Amount Match Check
713
+ if (BigInt(session.amountPaise) !== BigInt(parsedData.amountPaise)) {
714
+ const expectedAmount = (Number(session.amountPaise) / 100).toFixed(2);
715
+ const gotAmount = (Number(parsedData.amountPaise) / 100).toFixed(2);
716
+ stage4 = {
717
+ stage: 4,
718
+ name: 'VERIFICATION',
719
+ status: 'FAILED',
720
+ reason: 'AMOUNT_MISMATCH',
721
+ details: `Payment amount mismatch: session requires ₹${expectedAmount}, but observation contains ₹${gotAmount}.`
722
+ };
723
+ return buildResult({
724
+ finalStatus: 'REJECTED',
725
+ failingStage: 4,
726
+ failingCondition: 'AMOUNT_MISMATCH',
727
+ failingReason: stage4.details,
728
+ stage2,
729
+ stage3,
730
+ stage4,
731
+ parsed: parsedData,
732
+ session
733
+ });
734
+ }
735
+
736
+ // 4. Duplicate Check
737
+ const receivingAccountId = session.receivingAccountId || input.receivingAccountId || '00000000-0000-0000-0000-000000000002';
738
+ let observerSourceEnum;
739
+ if (isGmailSource) {
740
+ observerSourceEnum = ObserverSource.GMAIL;
741
+ } else if (rawNormalizedSource === ObserverSource.GOOGLE_MESSAGES) {
742
+ observerSourceEnum = ObserverSource.GOOGLE_MESSAGES;
743
+ } else if (rawNormalizedSource === ObserverSource.ANDROID_OBSERVER) {
744
+ observerSourceEnum = ObserverSource.ANDROID_OBSERVER;
745
+ } else {
746
+ observerSourceEnum = ObserverSource.SMS_GATEWAY;
747
+ }
748
+ const fingerprint = calculateObservationFingerprint({
749
+ receivingAccountId,
750
+ amountPaise: parsedData.amountPaise,
751
+ source: observerSourceEnum,
752
+ bankReferenceNumber: bankRef,
753
+ observedAt: receivedAt
754
+ });
755
+
756
+ const existingObs = this.obsRepo.findByFingerprint(fingerprint);
757
+ if (existingObs && existingObs.source === observerSourceEnum) {
758
+ stage4 = {
759
+ stage: 4,
760
+ name: 'VERIFICATION',
761
+ status: 'FAILED',
762
+ reason: 'DUPLICATE_OBSERVATION',
763
+ details: 'Duplicate observation detected. An identical observation fingerprint has already been processed.'
764
+ };
765
+ return buildResult({
766
+ finalStatus: 'REJECTED',
767
+ failingStage: 4,
768
+ failingCondition: 'DUPLICATE_OBSERVATION',
769
+ failingReason: stage4.details,
770
+ stage2,
771
+ stage3,
772
+ stage4,
773
+ parsed: parsedData,
774
+ session
775
+ });
776
+ }
777
+
778
+ // 5. Transaction Reference Already Used Check
779
+ const priorUsedSession = this.db.queryOne(`
780
+ SELECT ps.id, ps.order_id
781
+ FROM payment_sessions ps
782
+ JOIN payment_observations po ON ps.attributed_observation_id = po.id
783
+ WHERE ps.status = 'SUCCESS'
784
+ AND ps.id != ?
785
+ AND po.bank_reference_number = ?
786
+ LIMIT 1
787
+ `, [session.id, bankRef]);
788
+
789
+ if (priorUsedSession) {
790
+ stage4 = {
791
+ stage: 4,
792
+ name: 'VERIFICATION',
793
+ status: 'FAILED',
794
+ reason: 'REFERENCE_ALREADY_USED',
795
+ details: `Transaction reference '${bankRef}' has already been consumed by completed order #${priorUsedSession.order_id}.`
796
+ };
797
+ return buildResult({
798
+ finalStatus: 'REJECTED',
799
+ failingStage: 4,
800
+ failingCondition: 'REFERENCE_ALREADY_USED',
801
+ failingReason: stage4.details,
802
+ stage2,
803
+ stage3,
804
+ stage4,
805
+ parsed: parsedData,
806
+ session
807
+ });
808
+ }
809
+
810
+ stage4 = {
811
+ stage: 4,
812
+ name: 'VERIFICATION',
813
+ status: 'VERIFIED',
814
+ details: {
815
+ sessionId: session.id,
816
+ orderId: session.orderId,
817
+ amountMatches: true,
818
+ referenceValid: true,
819
+ temporalWindowValid: true
820
+ }
821
+ };
822
+
823
+ // =========================================================================
824
+ // [ STAGE 5 ]: Security Engine (EXACT Defined Fraud Scenario Only)
825
+ // =========================================================================
826
+ let stage5;
827
+ let isFraudulentInjection = false;
828
+ let threatType = null;
829
+ let threatDetails = null;
830
+
831
+ // Check if customer identity is already banned
832
+ if (customer && customer.status === 'BANNED') {
833
+ stage5 = {
834
+ stage: 5,
835
+ name: 'SECURITY',
836
+ status: 'FAILED',
837
+ reason: 'BANNED_IDENTITY',
838
+ details: 'Associated customer identity is permanently banned.'
839
+ };
840
+ return buildResult({
841
+ finalStatus: 'REJECTED',
842
+ failingStage: 5,
843
+ failingCondition: 'BANNED_IDENTITY',
844
+ failingReason: stage5.details,
845
+ stage2,
846
+ stage3,
847
+ stage4,
848
+ stage5,
849
+ parsed: parsedData,
850
+ session
851
+ });
852
+ }
853
+
854
+ // -------------------------------------------------------------------------
855
+ // DEFINED FRAUD DETECTION CONDITIONS (ALL 6 MUST APPLY):
856
+ // 1. There is an active or recently active payment session:
857
+ const isSessionActiveOrRecent = Boolean(session && (session.status === 'ACTIVE' || session.status === 'PROCESSING'));
858
+
859
+ // 2. The observation is associated with that customer's payment-session identity:
860
+ const hasAssociatedCustomer = Boolean(customer && customer.id);
861
+
862
+ // 3. The incoming observation is attributable to customer's registered email or phone:
863
+ // (calculated above as isAttributableToCustomer)
864
+
865
+ // 4. The content is attempting to represent a payment/credit transaction rather than being an ordinary unrelated message:
866
+ const isAttemptingCreditMessage = creditKeywords.test(rawText) && (
867
+ Boolean(bankRef) || Boolean(parsedData?.amountPaise) || currencyKeywords.test(rawText)
868
+ );
869
+
870
+ // 5. The observation is not a legitimate bank-originated transaction observation:
871
+ let isNotLegitimateBank = false;
872
+ if (source === 'SMS') {
873
+ const cleanSender = (sender || '').trim();
874
+ const isValidSenderCode = /[a-zA-Z]/.test(cleanSender) && /^[a-zA-Z0-9_\-\.]+$/.test(cleanSender);
875
+ isNotLegitimateBank = !isValidSenderCode || isAttributableToCustomer;
876
+ } else if (source === 'GMAIL') {
877
+ const senderDomain = sender.includes('@') ? sender.split('@')[1]?.toLowerCase().trim() : sender.toLowerCase().trim();
878
+ const isBankDomain = senderDomain.endsWith('.bank.in') || senderDomain === 'bank.in' || sender === 'TEST' || senderDomain === 'hdfcbank.net';
879
+ isNotLegitimateBank = !isBankDomain || isAttributableToCustomer;
880
+ } else {
881
+ isNotLegitimateBank = true;
882
+ }
883
+
884
+ // 6. The observation is being used to falsely satisfy payment verification for that customer's session:
885
+ const targetsCustomerSession = Boolean(session && (
886
+ session.customerId === customer?.id ||
887
+ input.paymentSessionId === session.id ||
888
+ (parsedData?.amountPaise && session.amountPaise && BigInt(parsedData.amountPaise) === BigInt(session.amountPaise))
889
+ ));
890
+
891
+ // Automatic banning should occur ONLY when all 6 conditions are positively established
892
+ if (isSessionActiveOrRecent &&
893
+ hasAssociatedCustomer &&
894
+ isAttributableToCustomer &&
895
+ isAttemptingCreditMessage &&
896
+ isNotLegitimateBank &&
897
+ targetsCustomerSession) {
898
+ isFraudulentInjection = true;
899
+ threatType = 'FRAUDULENT_PAYMENT_INJECTION';
900
+ threatDetails = `Customer '${customer.name}' attempted to inject fake payment notification attributable to registered identity (${matchedCustomerChannel}).`;
901
+ }
902
+
903
+ // Complete evidence preservation package
904
+ const evidence = {
905
+ customerId: customer?.id || null,
906
+ customerName: customer?.name || null,
907
+ customerEmail: customer?.email || null,
908
+ customerPhone: customer?.mobileNumber || null,
909
+ paymentSessionId: session?.id || null,
910
+ orderId: session?.orderId || null,
911
+ sessionState: session?.status || null,
912
+ sessionCreatedAt: session?.createdAt || null,
913
+ sessionAmountPaise: session?.amountPaise?.toString() || null,
914
+ sessionAmountRupees: session?.amountPaise ? (Number(session.amountPaise) / 100).toFixed(2) : null,
915
+ deviceInfo: input.deviceInfo || session?.metadata?.deviceInfo || 'Device Web Client',
916
+ source,
917
+ originatingSender: sender,
918
+ receivedAt: observation.received_at,
919
+ rawMessage: observation.raw, // EXACT RAW MESSAGE VERBATIM!
920
+ normalizedMessage: observation.body,
921
+ extractedAmount: parsedData?.amount ? `₹${parsedData.amount}` : null,
922
+ extractedReference: bankRef || null,
923
+ parserResults: stage3,
924
+ verificationResults: stage4,
925
+ fraudReason: 'FRAUDULENT_PAYMENT_INJECTION',
926
+ detectedAt: new Date().toISOString()
927
+ };
928
+
929
+ let autoBanApplied = false;
930
+ let securityDiagnostic = '';
931
+
932
+ if (isFraudulentInjection) {
933
+ const isExplicitlySuppressed = Boolean(input.suppressBan);
934
+
935
+ // Store security event in DB
936
+ if (this.customerRepo && typeof this.customerRepo.createSecurityEvent === 'function') {
937
+ this.customerRepo.createSecurityEvent({
938
+ merchantId: session.merchantId,
939
+ customerId: customer.id,
940
+ paymentSessionId: session.id,
941
+ eventType: 'FRAUDULENT_PAYMENT_INJECTION',
942
+ source,
943
+ sender,
944
+ deviceInfo: evidence.deviceInfo,
945
+ rawMessage: observation.raw,
946
+ normalizedMessage: observation.body,
947
+ extractedAmount: evidence.extractedAmount,
948
+ extractedReference: evidence.extractedReference,
949
+ parserResults: stage3,
950
+ verificationResults: stage4,
951
+ fraudReason: 'FRAUDULENT_PAYMENT_INJECTION',
952
+ metadata: {
953
+ orderId: session.orderId,
954
+ testModeActive,
955
+ autoBanApplied: !isExplicitlySuppressed,
956
+ banSuppressed: Boolean(isExplicitlySuppressed)
957
+ }
958
+ });
959
+ }
960
+
961
+ if (isExplicitlySuppressed) {
962
+ // Explicitly suppressed ban (e.g. for specialized mock tests)
963
+ autoBanApplied = false;
964
+ securityDiagnostic = 'FRAUDULENT_PAYMENT_INJECTION detected. Automatic banning suppressed by explicit request parameter.';
965
+
966
+ stage5 = {
967
+ stage: 5,
968
+ name: 'SECURITY',
969
+ status: 'SUSPICIOUS_FLAGGED',
970
+ isSuspicious: true,
971
+ threatType,
972
+ autoBanApplied: false,
973
+ banSuppressed: true,
974
+ diagnostic: securityDiagnostic,
975
+ details: evidence
976
+ };
977
+
978
+ if (this.auditRepo) {
979
+ this.auditRepo.log({
980
+ merchantId: session.merchantId,
981
+ entityType: 'PAYMENT_SESSION',
982
+ entityId: session.id,
983
+ action: 'TEST_MODE_FRAUD_FLAGGED_BAN_SUPPRESSED',
984
+ actorType: 'SYSTEM',
985
+ details: { threatType, customerId: customer.id, evidence }
986
+ });
987
+ }
988
+ } else {
989
+ // Automatic banning is ENABLED: Ban user instantly right away!
990
+ autoBanApplied = true;
991
+ this.customerRepo.banCustomer(customer.id, 'FRAUDULENT_PAYMENT_INJECTION', { lastDevice: evidence.deviceInfo });
992
+ securityDiagnostic = 'FRAUDULENT_PAYMENT_INJECTION detected. Associated customer identity has been automatically BANNED by Security Engine.';
993
+
994
+ stage5 = {
995
+ stage: 5,
996
+ name: 'SECURITY',
997
+ status: 'FAILED',
998
+ isSuspicious: true,
999
+ threatType,
1000
+ autoBanApplied: true,
1001
+ reason: 'FRAUDULENT_PAYMENT_INJECTION',
1002
+ diagnostic: securityDiagnostic,
1003
+ details: evidence
1004
+ };
1005
+
1006
+ if (this.auditRepo) {
1007
+ this.auditRepo.log({
1008
+ merchantId: session.merchantId,
1009
+ entityType: 'CUSTOMER',
1010
+ entityId: customer.id,
1011
+ action: 'CUSTOMER_AUTOMATICALLY_BANNED',
1012
+ actorType: 'SYSTEM',
1013
+ details: { threatType, rawText, evidence }
1014
+ });
1015
+ }
1016
+
1017
+ // Broadcast instant real-time termination to active client over SSE
1018
+ if (this.server && session?.id) {
1019
+ this.server.broadcastSse(session.id, 'status_change', {
1020
+ status: 'BLOCKED',
1021
+ isBanned: true,
1022
+ fraudDetected: true,
1023
+ orderId: session.orderId,
1024
+ reason: 'FRAUDULENT_PAYMENT_INJECTION',
1025
+ blockedNotice: {
1026
+ title: 'FRAUDULENT ACTIVITY DETECTED',
1027
+ subtitle: 'Payment Session Terminated & Account Suspended',
1028
+ message: 'Our automated verification engine detected fraudulent payment activity associated with this account. Access has been immediately suspended while our security operations team investigates the issue.',
1029
+ investigating: true
1030
+ }
1031
+ });
1032
+ this.server.broadcastSse(session.id, 'account_blocked', {
1033
+ status: 'BLOCKED',
1034
+ isBanned: true,
1035
+ fraudDetected: true,
1036
+ orderId: session.orderId
1037
+ });
1038
+ }
1039
+ if (this.eventEmitter) {
1040
+ this.eventEmitter.emit('PAYMENT.FRAUD_BLOCKED', {
1041
+ sessionId: session?.id,
1042
+ customerId: customer?.id,
1043
+ orderId: session?.orderId,
1044
+ reason: 'FRAUDULENT_PAYMENT_INJECTION'
1045
+ });
1046
+ }
1047
+
1048
+ // DO NOT CONFIRM PAYMENT - RETURN REJECTED IMMEDIATELY
1049
+ return buildResult({
1050
+ finalStatus: 'REJECTED',
1051
+ failingStage: 5,
1052
+ failingCondition: 'FRAUDULENT_PAYMENT_INJECTION',
1053
+ failingReason: securityDiagnostic,
1054
+ stage2,
1055
+ stage3,
1056
+ stage4,
1057
+ stage5,
1058
+ parsed: parsedData,
1059
+ session
1060
+ });
1061
+ }
1062
+ } else {
1063
+ stage5 = {
1064
+ stage: 5,
1065
+ name: 'SECURITY',
1066
+ status: 'PASSED',
1067
+ isSuspicious: false,
1068
+ autoBanApplied: false,
1069
+ details: 'Security and fraud pattern scans passed.'
1070
+ };
1071
+ }
1072
+
1073
+ // =========================================================================
1074
+ // [ STAGE 6 ]: Attribution
1075
+ // =========================================================================
1076
+ let stage6;
1077
+
1078
+ if (isFraudulentInjection) {
1079
+ // In Test Mode where auto-ban is suppressed: STILL DO NOT CONFIRM PAYMENT!
1080
+ stage6 = {
1081
+ stage: 6,
1082
+ name: 'ATTRIBUTION',
1083
+ status: 'FLAGGED_SUSPICIOUS',
1084
+ outcomeStatus: 'SUSPICIOUS',
1085
+ details: 'Payment held in SUSPICIOUS state (DO NOT PAY). Observation flagged for security review.'
1086
+ };
1087
+
1088
+ return buildResult({
1089
+ finalStatus: 'SUSPICIOUS',
1090
+ failingStage: 5,
1091
+ failingCondition: 'FRAUDULENT_PAYMENT_INJECTION',
1092
+ failingReason: securityDiagnostic,
1093
+ stage2,
1094
+ stage3,
1095
+ stage4,
1096
+ stage5,
1097
+ stage6,
1098
+ parsed: parsedData,
1099
+ session
1100
+ });
1101
+ }
1102
+
1103
+ // Execute standard attribution engine process
1104
+ const normalized = ObservationNormalizer.normalize({
1105
+ source: observerSourceEnum,
1106
+ receivingAccountId: session?.receivingAccountId || receivingAccountId,
1107
+ rawPayload: {
1108
+ ...observation,
1109
+ raw: rawText,
1110
+ is_test: isTest,
1111
+ targetSessionId: session?.id
1112
+ },
1113
+ amountPaise: parsedData.amountPaise,
1114
+ amount: parsedData.amount,
1115
+ observedAt: receivedAt,
1116
+ senderVpa: parsedData.senderVpa,
1117
+ bankReferenceNumber: parsedData.bankReferenceNumber
1118
+ });
1119
+
1120
+ const attributionResult = this.attributionEngine
1121
+ ? this.attributionEngine.processObservation(normalized)
1122
+ : {
1123
+ attributed: true,
1124
+ outcomeStatus: 'VERIFIED',
1125
+ observationId: normalized.id,
1126
+ paymentSessionId: session?.id,
1127
+ verificationLevel: 1,
1128
+ verificationTag: 'DEFAULT'
1129
+ };
1130
+
1131
+ stage6 = {
1132
+ stage: 6,
1133
+ name: 'ATTRIBUTION',
1134
+ status: attributionResult.attributed ? 'ATTRIBUTED' : 'UNRESOLVED',
1135
+ outcomeStatus: attributionResult.outcomeStatus,
1136
+ details: {
1137
+ attributed: attributionResult.attributed,
1138
+ paymentSessionId: attributionResult.paymentSessionId,
1139
+ observationId: attributionResult.observationId,
1140
+ attributedSessionId: attributionResult.paymentSessionId,
1141
+ outcomeStatus: attributionResult.outcomeStatus,
1142
+ verificationLevel: attributionResult.verificationLevel,
1143
+ verificationTag: attributionResult.verificationTag
1144
+ }
1145
+ };
1146
+
1147
+ const finalStatus = attributionResult.attributed ? 'VERIFIED' : 'UNRESOLVED';
1148
+
1149
+ // Refresh session state after attribution
1150
+ const updatedSession = this.sessionRepo.findById(session.id);
1151
+
1152
+ return buildResult({
1153
+ finalStatus,
1154
+ stage2,
1155
+ stage3,
1156
+ stage4,
1157
+ stage5,
1158
+ stage6,
1159
+ parsed: parsedData,
1160
+ session: updatedSession || session,
1161
+ attribution: attributionResult
1162
+ });
1163
+ }
1164
+ }