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,636 @@
1
+ import crypto from 'node:crypto';
2
+ import { hashPassword, verifyPassword, normalizeEmail, normalizeMobile, isValidEmail, isValidMobile, isValidPassword, safeEqual, encryptAtRest, decryptAtRest } from '../../common/index.js';
3
+
4
+ /** Wrong guesses tolerated per issued OTP before the challenge is burned. */
5
+ const OTP_MAX_ATTEMPTS = 5;
6
+
7
+ export class CustomerRepository {
8
+ /**
9
+ * @param {import('../database.js').Database} db
10
+ */
11
+ constructor(db) {
12
+ this.db = db;
13
+ }
14
+
15
+ /**
16
+ * Creates a new gateway customer.
17
+ * @param {{
18
+ * id?: string,
19
+ * name?: string,
20
+ * fullName?: string,
21
+ * email?: string,
22
+ * mobileNumber?: string,
23
+ * phoneNumber?: string,
24
+ * password?: string,
25
+ * passwordHash?: string,
26
+ * status?: string
27
+ * }} data
28
+ */
29
+ create(data) {
30
+ const id = data.id || crypto.randomUUID();
31
+ const name = String(data.name || data.fullName || '').trim();
32
+ const mobileNumber = normalizeMobile(data.mobileNumber || data.phoneNumber);
33
+ let email = normalizeEmail(data.email);
34
+ const status = data.status || 'ACTIVE';
35
+
36
+ if (!name) {
37
+ throw new Error('Customer name is required.');
38
+ }
39
+ if (!mobileNumber || !isValidMobile(mobileNumber)) {
40
+ throw new Error('Invalid mobile number format (must be a valid 10-digit number).');
41
+ }
42
+ if (!email) {
43
+ email = `${mobileNumber}@customer.elpay.local`;
44
+ } else if (!isValidEmail(email)) {
45
+ throw new Error('Invalid email address format.');
46
+ }
47
+
48
+ let passwordHash = data.passwordHash || null;
49
+ if (!passwordHash && data.password) {
50
+ if (!isValidPassword(data.password)) {
51
+ throw new Error('Password must be at least 6 characters long.');
52
+ }
53
+ passwordHash = hashPassword(data.password);
54
+ }
55
+
56
+ const otpCode = data.otpCode || null;
57
+ const otpExpiresAt = data.otpExpiresAt || null;
58
+ const isPhoneVerified = data.isPhoneVerified ? 1 : 0;
59
+ const now = new Date().toISOString();
60
+
61
+ const sql = `
62
+ INSERT INTO customers (id, name, email, mobile_number, password_hash, otp_code, otp_expires_at, is_phone_verified, status, created_at, updated_at)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
64
+ `;
65
+
66
+ this.db.execute(sql, [
67
+ id,
68
+ name,
69
+ email,
70
+ mobileNumber,
71
+ passwordHash,
72
+ otpCode,
73
+ otpExpiresAt,
74
+ isPhoneVerified,
75
+ status,
76
+ now,
77
+ now
78
+ ]);
79
+
80
+ return this.findById(id);
81
+ }
82
+
83
+ /**
84
+ * Finds customer by unique ID.
85
+ * @param {string} id
86
+ * @returns {any | null}
87
+ */
88
+ findById(id) {
89
+ if (!id) return null;
90
+ const row = this.db.queryOne('SELECT * FROM customers WHERE id = ?', [id]);
91
+ if (!row) return null;
92
+ return this.mapRow(row);
93
+ }
94
+
95
+ /**
96
+ * Finds customer by normalized email.
97
+ * @param {string} email
98
+ * @returns {any | null}
99
+ */
100
+ findByEmail(email) {
101
+ const normalized = normalizeEmail(email);
102
+ if (!normalized) return null;
103
+ const row = this.db.queryOne('SELECT * FROM customers WHERE email = ?', [normalized]);
104
+ if (!row) return null;
105
+ return this.mapRow(row);
106
+ }
107
+
108
+ /**
109
+ * Finds customer by normalized mobile number.
110
+ * @param {string} mobileNumber
111
+ * @returns {any | null}
112
+ */
113
+ findByMobile(mobileNumber) {
114
+ const normalized = normalizeMobile(mobileNumber);
115
+ if (!normalized) return null;
116
+ const clean10 = normalized.slice(-10);
117
+ const row = this.db.queryOne(
118
+ 'SELECT * FROM customers WHERE mobile_number = ? OR mobile_number = ? OR mobile_number = ? OR mobile_number LIKE ?',
119
+ [normalized, clean10, '+91' + clean10, '%' + clean10]
120
+ );
121
+ if (!row) return null;
122
+ return this.mapRow(row);
123
+ }
124
+
125
+ /**
126
+ * Alias for backwards compatibility with tests and callers expecting findByPhone.
127
+ * @param {string} arg1 merchantId or phoneNumber
128
+ * @param {string} [arg2] optional phoneNumber if merchantId was first
129
+ * @returns {any | null}
130
+ */
131
+ findByPhone(arg1, arg2) {
132
+ const phone = arg2 || arg1;
133
+ return this.findByMobile(phone);
134
+ }
135
+
136
+ /**
137
+ * Finds customer by either email or mobile number identifier.
138
+ * @param {string} identifier
139
+ * @returns {any | null}
140
+ */
141
+ findByIdentifier(identifier) {
142
+ if (!identifier) return null;
143
+ const trimmed = String(identifier).trim();
144
+ if (trimmed.includes('@')) {
145
+ return this.findByEmail(trimmed);
146
+ }
147
+ const asMobile = this.findByMobile(trimmed);
148
+ if (asMobile) return asMobile;
149
+ return this.findByEmail(trimmed);
150
+ }
151
+
152
+ /**
153
+ * Verifies customer login credentials using email or mobile + password.
154
+ * @param {string|any} arg1 Identifier or merchantId
155
+ * @param {string} arg2 Password or phoneNumber
156
+ * @param {string} [arg3] Optional password if merchantId was first
157
+ * @returns {any | null} Safe customer profile or null
158
+ */
159
+ verifyCredentials(arg1, arg2, arg3) {
160
+ let identifier = arg1;
161
+ let password = arg2;
162
+ if (arg3 !== undefined) {
163
+ identifier = arg2;
164
+ password = arg3;
165
+ }
166
+
167
+ if (!identifier || !password) return null;
168
+
169
+ const customer = this.findByIdentifier(identifier);
170
+ if (!customer) return null;
171
+
172
+ if (customer.status !== 'ACTIVE') return null;
173
+
174
+ const isValid = verifyPassword(password, customer.passwordHash);
175
+ if (!isValid) return null;
176
+
177
+ // Update last_login_at
178
+ const now = new Date().toISOString();
179
+ try {
180
+ this.db.execute('UPDATE customers SET last_login_at = ?, updated_at = ? WHERE id = ?', [now, now, customer.id]);
181
+ } catch {}
182
+
183
+ const { passwordHash, ...safeCustomer } = customer;
184
+ safeCustomer.lastLoginAt = new Date(now);
185
+ return safeCustomer;
186
+ }
187
+
188
+ /**
189
+ * Stores WhatsApp OTP challenge for a customer mobile number.
190
+ * @param {string} mobileNumber
191
+ * @param {string} otpCode
192
+ * @param {string} expiresAt ISO String
193
+ * @returns {any | null}
194
+ */
195
+ setOtp(mobileNumber, otpCode, expiresAt) {
196
+ const normalized = normalizeMobile(mobileNumber);
197
+ if (!normalized) return null;
198
+ const now = new Date().toISOString();
199
+ this.db.execute(
200
+ 'UPDATE customers SET otp_code = ?, otp_expires_at = ?, otp_attempts = 0, updated_at = ? WHERE mobile_number = ?',
201
+ [encryptAtRest(otpCode), expiresAt, now, normalized]
202
+ );
203
+ return this.findByMobile(normalized);
204
+ }
205
+
206
+ /**
207
+ * Verifies WhatsApp OTP for customer login/signup.
208
+ * Enforces a bounded attempt count per challenge and compares the code in constant time.
209
+ * Clears the OTP and marks phone as verified on success.
210
+ * @param {string} mobileNumber
211
+ * @param {string} otpCode
212
+ * @returns {any | null}
213
+ */
214
+ verifyOtp(mobileNumber, otpCode) {
215
+ const normalized = normalizeMobile(mobileNumber);
216
+ if (!normalized || !otpCode) return null;
217
+ const customer = this.findByMobile(normalized);
218
+ if (!customer) return null;
219
+
220
+ // A 6-digit code has a 10^6 keyspace and a 5 minute lifetime, so without an attempt
221
+ // budget it is brute-forceable online. Fail closed once the budget is spent.
222
+ const attempts = Number(customer.otpAttempts || 0);
223
+ if (attempts >= OTP_MAX_ATTEMPTS) return null;
224
+
225
+ const submitted = String(otpCode).trim();
226
+ const storedOtp = decryptAtRest(customer.otpCode);
227
+ // `!==` short-circuits on the first differing character; use a constant-time compare.
228
+ const matches = storedOtp ? safeEqual(storedOtp, submitted) : false;
229
+ if (!storedOtp || !matches) {
230
+ this.db.execute(
231
+ 'UPDATE customers SET otp_attempts = ?, updated_at = ? WHERE id = ?',
232
+ [attempts + 1, new Date().toISOString(), customer.id]
233
+ );
234
+ return null;
235
+ }
236
+ if (customer.otpExpiresAt && new Date() > customer.otpExpiresAt) {
237
+ return null; // Expired
238
+ }
239
+
240
+ const now = new Date().toISOString();
241
+ this.db.execute(
242
+ 'UPDATE customers SET otp_code = NULL, otp_expires_at = NULL, otp_attempts = 0, is_phone_verified = 1, last_login_at = ?, updated_at = ? WHERE id = ?',
243
+ [now, now, customer.id]
244
+ );
245
+ return this.findById(customer.id);
246
+ }
247
+
248
+ /**
249
+ * Completely wipes all customer identity records.
250
+ */
251
+ clearAll() {
252
+ this.db.execute('DELETE FROM customer_sessions');
253
+ this.db.execute('DELETE FROM customers');
254
+ }
255
+
256
+ /**
257
+ * Returns count of customers in the database.
258
+ * @returns {number}
259
+ */
260
+ count() {
261
+ const row = this.db.queryOne('SELECT COUNT(*) as c FROM customers');
262
+ return row?.c || 0;
263
+ }
264
+
265
+ /**
266
+ * Bans a customer by ID.
267
+ * @param {string} id
268
+ * @param {string} [reason]
269
+ * @returns {any | null}
270
+ */
271
+ /**
272
+ * Bans a customer by ID.
273
+ * @param {string} id
274
+ * @param {string} [reason='FRAUDULENT_PAYMENT_INJECTION']
275
+ * @param {{ lastDevice?: string, deviceInfo?: string }} [options={}]
276
+ * @returns {any | null}
277
+ */
278
+ banCustomer(id, reason = 'FRAUDULENT_PAYMENT_INJECTION', options = {}) {
279
+ if (!id) return null;
280
+ const releasedAccounts = [];
281
+ this.db.transaction(() => {
282
+ const now = new Date().toISOString();
283
+ const lastDevice = options.lastDevice || options.deviceInfo || null;
284
+ this.db.execute(
285
+ "UPDATE customers SET status = 'BANNED', ban_reason = ?, banned_at = ?, last_device = COALESCE(?, last_device), updated_at = ? WHERE id = ?",
286
+ [reason, now, lastDevice, now, id]
287
+ );
288
+
289
+ // Immediately transition all existing non-terminal sessions for this customer to EXPIRED
290
+ const activeSessions = this.db.query(
291
+ "SELECT id, receiving_account_id FROM payment_sessions WHERE customer_id = ? AND status IN ('INITIATED', 'QUEUED', 'ACTIVE', 'PROCESSING')",
292
+ [id]
293
+ );
294
+ if (activeSessions.length > 0) {
295
+ this.db.execute(
296
+ "UPDATE payment_sessions SET status = 'EXPIRED', completed_at = ?, updated_at = ? WHERE customer_id = ? AND status IN ('INITIATED', 'QUEUED', 'ACTIVE', 'PROCESSING')",
297
+ [now, now, id]
298
+ );
299
+ for (const s of activeSessions) {
300
+ if (s.receiving_account_id) {
301
+ this.db.execute(
302
+ "UPDATE payment_receiving_accounts SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ? WHERE id = ? AND current_active_session_id = ?",
303
+ [now, s.receiving_account_id, s.id]
304
+ );
305
+ releasedAccounts.push(s.receiving_account_id);
306
+ }
307
+ }
308
+ }
309
+ });
310
+
311
+ if (options.queueEngine && typeof options.queueEngine.promoteNext === 'function') {
312
+ for (const accountId of releasedAccounts) {
313
+ options.queueEngine.promoteNext(accountId);
314
+ }
315
+ }
316
+
317
+ return this.findById(id);
318
+ }
319
+
320
+ /**
321
+ * Unbans a customer by ID.
322
+ * @param {string} id
323
+ * @returns {any | null}
324
+ */
325
+ unbanCustomer(id) {
326
+ if (!id) return null;
327
+ const now = new Date().toISOString();
328
+ this.db.execute(
329
+ "UPDATE customers SET status = 'ACTIVE', ban_reason = NULL, unbanned_at = ?, updated_at = ? WHERE id = ?",
330
+ [now, now, id]
331
+ );
332
+ return this.findById(id);
333
+ }
334
+
335
+ /**
336
+ * Checks if customer is banned.
337
+ * @param {string} id
338
+ * @returns {boolean}
339
+ */
340
+ isBanned(id) {
341
+ if (!id) return false;
342
+ const customer = this.findById(id);
343
+ return customer?.status === 'BANNED';
344
+ }
345
+
346
+ /**
347
+ * Creates and persists a security event record with complete forensic evidence.
348
+ * @param {{
349
+ * id?: string,
350
+ * merchantId?: string,
351
+ * customerId?: string,
352
+ * paymentSessionId?: string,
353
+ * eventType: string,
354
+ * source?: string,
355
+ * sender?: string,
356
+ * deviceInfo?: string,
357
+ * rawMessage: string,
358
+ * normalizedMessage?: string,
359
+ * extractedAmount?: string,
360
+ * extractedReference?: string,
361
+ * parserResults?: any,
362
+ * verificationResults?: any,
363
+ * fraudReason: string,
364
+ * metadata?: any
365
+ * }} data
366
+ * @returns {any}
367
+ */
368
+ createSecurityEvent(data) {
369
+ const id = data.id || crypto.randomUUID();
370
+ const now = new Date().toISOString();
371
+ const sql = `
372
+ INSERT INTO security_events (
373
+ id, merchant_id, customer_id, payment_session_id, event_type,
374
+ source, sender, device_info, raw_message, normalized_message,
375
+ extracted_amount, extracted_reference, parser_results, verification_results,
376
+ fraud_reason, metadata, created_at
377
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
378
+ `;
379
+ this.db.execute(sql, [
380
+ id,
381
+ data.merchantId || null,
382
+ data.customerId || null,
383
+ data.paymentSessionId || null,
384
+ data.eventType || 'FRAUDULENT_PAYMENT_INJECTION',
385
+ data.source || null,
386
+ data.sender || null,
387
+ data.deviceInfo || null,
388
+ data.rawMessage || '',
389
+ data.normalizedMessage || null,
390
+ data.extractedAmount || null,
391
+ data.extractedReference || null,
392
+ typeof data.parserResults === 'object' ? JSON.stringify(data.parserResults) : (data.parserResults || '{}'),
393
+ typeof data.verificationResults === 'object' ? JSON.stringify(data.verificationResults) : (data.verificationResults || '{}'),
394
+ data.fraudReason || 'FRAUDULENT_PAYMENT_INJECTION',
395
+ typeof data.metadata === 'object' ? JSON.stringify(data.metadata) : (data.metadata || '{}'),
396
+ now
397
+ ]);
398
+ return this.db.queryOne('SELECT * FROM security_events WHERE id = ?', [id]);
399
+ }
400
+
401
+ /**
402
+ * Finds all security events for customer or all security events.
403
+ * @param {string} [customerId]
404
+ * @param {number} [limit=100]
405
+ * @returns {any[]}
406
+ */
407
+ getSecurityEvents(customerId = null, limit = 100) {
408
+ const mapSecRow = (row) => {
409
+ let meta = {};
410
+ try { meta = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : (row.metadata || {}); } catch {}
411
+ return {
412
+ ...row,
413
+ customerId: row.customer_id,
414
+ paymentSessionId: row.payment_session_id,
415
+ eventType: row.event_type,
416
+ rawMessage: row.raw_message,
417
+ normalizedMessage: row.normalized_message,
418
+ extractedAmount: row.extracted_amount,
419
+ extractedReference: row.extracted_reference,
420
+ fraudReason: row.fraud_reason,
421
+ createdAt: row.created_at,
422
+ metadata: meta,
423
+ autoBanApplied: meta.autoBanApplied !== undefined ? Boolean(meta.autoBanApplied) : (row.event_type === 'FRAUDULENT_PAYMENT_INJECTION'),
424
+ banSuppressed: meta.banSuppressed !== undefined ? Boolean(meta.banSuppressed) : false
425
+ };
426
+ };
427
+
428
+ if (customerId) {
429
+ const rows = this.db.query(
430
+ 'SELECT * FROM security_events WHERE customer_id = ? ORDER BY created_at DESC LIMIT ?',
431
+ [customerId, limit]
432
+ );
433
+ return rows.map(mapSecRow);
434
+ }
435
+ const rows = this.db.query(
436
+ 'SELECT se.*, c.name as customer_name, c.email as customer_email, c.mobile_number as customer_phone FROM security_events se LEFT JOIN customers c ON se.customer_id = c.id ORDER BY se.created_at DESC LIMIT ?',
437
+ [limit]
438
+ );
439
+ return rows.map(mapSecRow);
440
+ }
441
+
442
+ /**
443
+ * Retrieves all banned customers with their latest security event and payment session details.
444
+ * @param {string} [searchQuery='']
445
+ * @returns {any[]}
446
+ */
447
+ findAllBanned(searchQuery = '') {
448
+ let sql = `
449
+ SELECT c.*,
450
+ se.id as security_event_id,
451
+ se.event_type,
452
+ se.source as event_source,
453
+ se.sender as event_sender,
454
+ se.device_info as event_device_info,
455
+ se.fraud_reason as event_fraud_reason,
456
+ se.raw_message as event_raw_message,
457
+ se.created_at as event_created_at,
458
+ ps.id as session_id,
459
+ ps.order_id,
460
+ ps.amount_paise,
461
+ ps.status as session_status
462
+ FROM customers c
463
+ LEFT JOIN (
464
+ SELECT * FROM security_events ORDER BY created_at DESC
465
+ ) se ON se.customer_id = c.id
466
+ LEFT JOIN payment_sessions ps ON ps.id = se.payment_session_id
467
+ WHERE c.status = 'BANNED'
468
+ `;
469
+ const params = [];
470
+ if (searchQuery && searchQuery.trim()) {
471
+ const q = `%${searchQuery.trim()}%`;
472
+ sql += ` AND (c.name LIKE ? OR c.email LIKE ? OR c.mobile_number LIKE ?)`;
473
+ params.push(q, q, q);
474
+ }
475
+ sql += ` GROUP BY c.id ORDER BY COALESCE(c.banned_at, c.updated_at) DESC`;
476
+
477
+ const rows = this.db.query(sql, params);
478
+ return rows.map(r => ({
479
+ id: r.id,
480
+ name: r.name,
481
+ email: r.email,
482
+ phone: r.mobile_number,
483
+ mobileNumber: r.mobile_number,
484
+ status: r.status,
485
+ bannedAt: r.banned_at || r.event_created_at || r.updated_at,
486
+ banReason: r.ban_reason || r.event_fraud_reason || 'FRAUDULENT_PAYMENT_INJECTION',
487
+ paymentSession: r.order_id ? `#${r.order_id}` : (r.payment_session_id || 'N/A'),
488
+ orderId: r.order_id || null,
489
+ sessionId: r.session_id || null,
490
+ lastDevice: r.last_device || r.event_device_info || 'Device Web Client',
491
+ source: r.event_source || 'SMS',
492
+ securityEventId: r.security_event_id || null,
493
+ rawMessage: r.event_raw_message || null
494
+ }));
495
+ }
496
+
497
+ /**
498
+ * Retrieves complete ban evidence package for a specific customer.
499
+ * @param {string} customerId
500
+ * @returns {any | null}
501
+ */
502
+ getBanEvidence(customerId) {
503
+ const customer = this.findById(customerId);
504
+ if (!customer) return null;
505
+
506
+ const events = this.getSecurityEvents(customerId, 50);
507
+ const triggerEvent = events.find(e => e.eventType === 'FRAUDULENT_PAYMENT_INJECTION') || events[0] || null;
508
+
509
+ let session = null;
510
+ if (triggerEvent?.paymentSessionId || triggerEvent?.payment_session_id) {
511
+ const sessId = triggerEvent.paymentSessionId || triggerEvent.payment_session_id;
512
+ session = this.db.queryOne('SELECT * FROM payment_sessions WHERE id = ?', [sessId]);
513
+ } else {
514
+ session = this.db.queryOne('SELECT * FROM payment_sessions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1', [customerId]);
515
+ }
516
+
517
+ const allSessions = this.db.query(
518
+ 'SELECT status, amount_paise FROM payment_sessions WHERE customer_id = ?',
519
+ [customerId]
520
+ );
521
+ const totalOrders = allSessions.length;
522
+ const totalSpentPaise = allSessions
523
+ .filter(s => s.status === 'SUCCESS')
524
+ .reduce((sum, s) => sum + Number(s.amount_paise || 0), 0);
525
+
526
+ const rawVerbatim = triggerEvent?.rawMessage || triggerEvent?.raw_message || 'No raw message preserved.';
527
+
528
+ return {
529
+ customer: {
530
+ id: customer.id,
531
+ name: customer.name,
532
+ email: customer.email,
533
+ phone: customer.mobileNumber,
534
+ status: customer.status,
535
+ createdAt: customer.createdAt,
536
+ bannedAt: customer.bannedAt || triggerEvent?.createdAt || customer.updatedAt,
537
+ banReason: customer.banReason || triggerEvent?.fraudReason || 'FRAUDULENT_PAYMENT_INJECTION',
538
+ lastDevice: customer.lastDevice || triggerEvent?.device_info || 'Device Web Client',
539
+ totalOrders,
540
+ totalSpentPaise,
541
+ totalSpentRupees: (totalSpentPaise / 100).toFixed(2)
542
+ },
543
+ banDetails: {
544
+ banType: customer.banReason === 'MANUAL_BAN' ? 'MANUAL' : 'AUTOMATIC',
545
+ triggerEvent: triggerEvent ? (triggerEvent.eventType || triggerEvent.event_type) : (customer.banReason || 'FRAUDULENT_PAYMENT_INJECTION'),
546
+ triggerTimestamp: customer.bannedAt || triggerEvent?.createdAt || null,
547
+ paymentSessionId: session ? session.id : (triggerEvent?.paymentSessionId || null),
548
+ orderId: session ? session.order_id : null,
549
+ sessionAmountPaise: session ? session.amount_paise : null,
550
+ sessionAmountRupees: session ? (Number(session.amount_paise) / 100).toFixed(2) : null
551
+ },
552
+ rawEvidence: rawVerbatim,
553
+ rawEvidenceObject: {
554
+ rawMessage: rawVerbatim,
555
+ normalizedMessage: triggerEvent?.normalizedMessage || triggerEvent?.normalized_message || null,
556
+ extractedAmount: triggerEvent?.extractedAmount || triggerEvent?.extracted_amount || null,
557
+ extractedReference: triggerEvent?.extractedReference || triggerEvent?.extracted_reference || null
558
+ },
559
+ observationMetadata: {
560
+ source: triggerEvent?.source || 'SMS',
561
+ sender: triggerEvent?.sender || customer.mobileNumber,
562
+ receiver: 'SIM Slot #1',
563
+ deviceId: triggerEvent?.device_info || customer.lastDevice || 'android-gateway'
564
+ },
565
+ discrepancyAnalysis: {
566
+ customerPhone: customer.mobileNumber,
567
+ observationSender: triggerEvent?.sender || customer.mobileNumber,
568
+ registeredBankHeader: 'NO (Personal sender match)',
569
+ sessionAmount: session ? `Rs ${(Number(session.amount_paise) / 100).toFixed(2)}` : 'N/A',
570
+ messageAmount: triggerEvent?.extractedAmount || 'N/A',
571
+ sessionRef: session?.id ? session.id.substring(0, 12) : 'N/A',
572
+ messageRef: triggerEvent?.extractedReference || 'N/A',
573
+ result: 'FRAUDULENT_PAYMENT_INJECTION confirmed.'
574
+ },
575
+ timeline: {
576
+ sessionCreated: session?.created_at || null,
577
+ fakeObservationReceived: triggerEvent?.createdAt || null,
578
+ paymentRejected: triggerEvent?.createdAt || null,
579
+ autoBanExecuted: customer.bannedAt || triggerEvent?.createdAt || null
580
+ },
581
+ session: session ? {
582
+ id: session.id,
583
+ orderId: session.order_id,
584
+ amount: session.amount_paise ? `₹${(Number(session.amount_paise) / 100).toFixed(2)}` : null,
585
+ amountPaise: session.amount_paise?.toString(),
586
+ createdAt: session.created_at,
587
+ state: session.status
588
+ } : null,
589
+ securityEvent: triggerEvent ? {
590
+ id: triggerEvent.id,
591
+ detectionTime: triggerEvent.createdAt,
592
+ fraudReason: triggerEvent.fraudReason || triggerEvent.fraud_reason,
593
+ source: triggerEvent.source,
594
+ sender: triggerEvent.sender,
595
+ deviceInfo: triggerEvent.device_info,
596
+ eventType: triggerEvent.eventType || triggerEvent.event_type
597
+ } : null,
598
+ history: events.map(e => ({
599
+ id: e.id,
600
+ eventType: e.eventType || e.event_type,
601
+ fraudReason: e.fraudReason || e.fraud_reason,
602
+ source: e.source,
603
+ sender: e.sender,
604
+ timestamp: e.createdAt || e.created_at,
605
+ rawMessage: e.rawMessage || e.raw_message
606
+ }))
607
+ };
608
+ }
609
+
610
+ /**
611
+ * Maps SQLite row to camelCase domain model with backward-compatibility aliases.
612
+ */
613
+ mapRow(row) {
614
+ return {
615
+ id: row.id,
616
+ name: row.name,
617
+ fullName: row.name,
618
+ email: row.email,
619
+ mobileNumber: row.mobile_number,
620
+ phoneNumber: row.mobile_number,
621
+ passwordHash: row.password_hash,
622
+ otpCode: decryptAtRest(row.otp_code),
623
+ otpExpiresAt: row.otp_expires_at ? new Date(row.otp_expires_at) : null,
624
+ otpAttempts: Number(row.otp_attempts || 0),
625
+ isPhoneVerified: Boolean(row.is_phone_verified),
626
+ status: row.status,
627
+ banReason: row.ban_reason || null,
628
+ bannedAt: row.banned_at ? new Date(row.banned_at) : null,
629
+ unbannedAt: row.unbanned_at ? new Date(row.unbanned_at) : null,
630
+ lastDevice: row.last_device || null,
631
+ lastLoginAt: row.last_login_at ? new Date(row.last_login_at) : null,
632
+ createdAt: new Date(row.created_at),
633
+ updatedAt: new Date(row.updated_at)
634
+ };
635
+ }
636
+ }