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,618 @@
1
+ import crypto from 'node:crypto';
2
+ import { PaymentStatus, PaymentMethod, isValidTransition, TERMINAL_STATES } from '../../common/index.js';
3
+
4
+ export class PaymentSessionRepository {
5
+ /**
6
+ * @param {import('../database.js').Database} db
7
+ */
8
+ constructor(db) {
9
+ this.db = db;
10
+ }
11
+
12
+ create(data) {
13
+ const id = data.id || crypto.randomUUID();
14
+ const now = new Date().toISOString();
15
+ const metadata = typeof data.metadata === 'object' ? JSON.stringify(data.metadata) : (data.metadata || '{}');
16
+ const attemptId = data.attemptId || id;
17
+
18
+ const sql = `
19
+ INSERT INTO payment_sessions (
20
+ id, merchant_id, customer_id, receiving_account_id, order_id, attempt_id,
21
+ amount_paise, currency, method, status, queue_position,
22
+ return_url, idempotency_key, device_id, verification_level, verification_sources,
23
+ verification_tag, metadata, created_at, updated_at
24
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '[]', 'UNVERIFIED', ?, ?, ?)
25
+ `;
26
+
27
+ this.db.execute(sql, [
28
+ id,
29
+ data.merchantId,
30
+ data.customerId || null,
31
+ data.receivingAccountId || null,
32
+ data.orderId,
33
+ attemptId,
34
+ Number(data.amountPaise),
35
+ data.currency || 'INR',
36
+ data.method || PaymentMethod.UPI,
37
+ data.status || PaymentStatus.INITIATED,
38
+ data.queuePosition || 0,
39
+ data.returnUrl || null,
40
+ data.idempotencyKey || null,
41
+ data.deviceId || null,
42
+ metadata,
43
+ now,
44
+ now
45
+ ]);
46
+
47
+ return this.findById(id);
48
+ }
49
+
50
+ findById(id) {
51
+ const row = this.db.queryOne('SELECT * FROM payment_sessions WHERE id = ?', [id]);
52
+ if (!row) return null;
53
+ return this.mapRow(row);
54
+ }
55
+
56
+ findByOrderId(merchantId, orderId) {
57
+ // The schema allows a second row for the same (merchant, order) once the previous one
58
+ // is terminal, so the newest row must win. Without an explicit ordering a stale
59
+ // terminal row could be returned and the caller would miss a SUCCESS session.
60
+ const row = this.db.queryOne(
61
+ 'SELECT * FROM payment_sessions WHERE merchant_id = ? AND order_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
62
+ [merchantId, orderId]
63
+ );
64
+ if (!row) return null;
65
+ return this.mapRow(row);
66
+ }
67
+
68
+ findByCustomerId(customerId) {
69
+ const rows = this.db.query(
70
+ 'SELECT * FROM payment_sessions WHERE customer_id = ? ORDER BY created_at DESC',
71
+ [customerId]
72
+ );
73
+ return rows.map(r => this.mapRow(r));
74
+ }
75
+
76
+ findByMerchantId(merchantId, limit = 50) {
77
+ const rows = this.db.query(
78
+ 'SELECT * FROM payment_sessions WHERE merchant_id = ? ORDER BY created_at DESC LIMIT ?',
79
+ [merchantId, limit]
80
+ );
81
+ return rows.map(r => this.mapRow(r));
82
+ }
83
+
84
+ /**
85
+ * Atomic slot acquisition transaction for UPI payments.
86
+ * Invariant: Only ONE active or processing session per receiving account.
87
+ * If slot is free, session transitions to ACTIVE with 5:00 window.
88
+ * If slot is occupied, session transitions to QUEUED with sequence position.
89
+ */
90
+ acquireSlotAtomic(sessionId, receivingAccountId, durationMs = 300000) {
91
+ return this.db.transaction(() => {
92
+ const now = new Date();
93
+ const nowIso = now.toISOString();
94
+
95
+ // Check for current active or processing session on this receiving account
96
+ let activeRow = this.db.queryOne(`
97
+ SELECT id, status, expires_at, processing_expires_at
98
+ FROM payment_sessions
99
+ WHERE receiving_account_id = ? AND status IN (?, ?)
100
+ LIMIT 1
101
+ `, [receivingAccountId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
102
+
103
+ if (activeRow) {
104
+ const isStale = (activeRow.status === PaymentStatus.ACTIVE && activeRow.expires_at && new Date(activeRow.expires_at) <= now) ||
105
+ (activeRow.status === PaymentStatus.PROCESSING && activeRow.processing_expires_at && new Date(activeRow.processing_expires_at) <= now);
106
+ if (isStale) {
107
+ const terminalStatus = activeRow.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
108
+ if (!isValidTransition(activeRow.status, terminalStatus)) {
109
+ throw new Error(`Illegal payment state transition: cannot move from '${activeRow.status}' to '${terminalStatus}'.`);
110
+ }
111
+ this.db.execute(`
112
+ UPDATE payment_sessions
113
+ SET status = ?, completed_at = ?, updated_at = ?
114
+ WHERE id = ?
115
+ `, [terminalStatus, nowIso, nowIso, activeRow.id]);
116
+ activeRow = null;
117
+ }
118
+ }
119
+
120
+ // CRITICAL GUARD: If activeRow is THIS session itself, it's ALREADY ACTIVE! Do not demote to QUEUED!
121
+ if (activeRow && activeRow.id === sessionId) {
122
+ return {
123
+ status: PaymentStatus.ACTIVE,
124
+ queuePosition: 0,
125
+ expiresAt: activeRow.expires_at ? new Date(activeRow.expires_at) : new Date(now.getTime() + durationMs)
126
+ };
127
+ }
128
+
129
+ if (!activeRow) {
130
+ // Slot is vacant! Grant ACTIVE slot.
131
+ // Enforce the transition graph: a terminal or PROCESSING session must never be
132
+ // revived to ACTIVE by a concurrent queue tick.
133
+ const currentStatus = this.findById(sessionId)?.status;
134
+ if (currentStatus && !isValidTransition(currentStatus, PaymentStatus.ACTIVE)) {
135
+ throw new Error(`Illegal payment state transition: cannot move from '${currentStatus}' to 'ACTIVE'.`);
136
+ }
137
+
138
+ const expiresAt = new Date(now.getTime() + durationMs).toISOString();
139
+
140
+ this.db.execute(`
141
+ UPDATE payment_sessions
142
+ SET status = ?, slot_acquired_at = ?, expires_at = ?, queue_position = 0, updated_at = ?
143
+ WHERE id = ?
144
+ `, [PaymentStatus.ACTIVE, nowIso, expiresAt, nowIso, sessionId]);
145
+
146
+ // Mark receiving account as busy
147
+ this.db.execute(`
148
+ UPDATE payment_receiving_accounts
149
+ SET is_slot_busy = 1, current_active_session_id = ?, updated_at = ?
150
+ WHERE id = ?
151
+ `, [sessionId, nowIso, receivingAccountId]);
152
+
153
+ return {
154
+ status: PaymentStatus.ACTIVE,
155
+ queuePosition: 0,
156
+ expiresAt: new Date(expiresAt)
157
+ };
158
+ } else {
159
+ // Slot is busy! Place in QUEUED
160
+ const existingSession = this.findById(sessionId);
161
+ let queuePosition = existingSession?.queuePosition;
162
+ if (!queuePosition || queuePosition <= 0) {
163
+ const posRow = this.db.queryOne(`
164
+ SELECT COALESCE(MAX(queue_position), 0) + 1 AS next_pos
165
+ FROM payment_sessions
166
+ WHERE receiving_account_id = ? AND status = ?
167
+ `, [receivingAccountId, PaymentStatus.QUEUED]);
168
+
169
+ queuePosition = posRow ? posRow.next_pos : 1;
170
+ }
171
+
172
+ if (existingSession && !isValidTransition(existingSession.status, PaymentStatus.QUEUED)) {
173
+ throw new Error(`Illegal payment state transition: cannot move from '${existingSession.status}' to 'QUEUED'.`);
174
+ }
175
+
176
+ this.db.execute(`
177
+ UPDATE payment_sessions
178
+ SET status = ?, queue_position = ?, updated_at = ?
179
+ WHERE id = ?
180
+ `, [PaymentStatus.QUEUED, queuePosition, nowIso, sessionId]);
181
+
182
+ return {
183
+ status: PaymentStatus.QUEUED,
184
+ queuePosition,
185
+ expiresAt: null
186
+ };
187
+ }
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Transitions an ACTIVE session to PROCESSING upon customer clicking Payment Done.
193
+ * Starts independent 30-second verification timer.
194
+ */
195
+ transitionToProcessing(sessionId, durationMs = 30000) {
196
+ return this.db.transaction(() => {
197
+ const session = this.findById(sessionId);
198
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
199
+
200
+ // Idempotency: if already processing, return existing
201
+ if (session.status === PaymentStatus.PROCESSING) {
202
+ return session;
203
+ }
204
+
205
+ if (session.status !== PaymentStatus.ACTIVE) {
206
+ throw new Error(`Cannot transition to PROCESSING from status: ${session.status}`);
207
+ }
208
+
209
+ const now = new Date();
210
+ const nowIso = now.toISOString();
211
+ const processingExpiresAt = new Date(now.getTime() + durationMs).toISOString();
212
+
213
+ this.db.execute(`
214
+ UPDATE payment_sessions
215
+ SET status = ?, processing_started_at = ?, processing_expires_at = ?, updated_at = ?
216
+ WHERE id = ? AND status = ?
217
+ `, [PaymentStatus.PROCESSING, nowIso, processingExpiresAt, nowIso, sessionId, PaymentStatus.ACTIVE]);
218
+
219
+ return this.findById(sessionId);
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Transitions session to terminal state (SUCCESS, FAILED, EXPIRED, CANCELLED).
225
+ * Atomically releases receiving account slot.
226
+ */
227
+ transitionToTerminal(sessionId, targetStatus, options = {}) {
228
+ return this.db.transaction(() => {
229
+ const session = this.findById(sessionId);
230
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
231
+
232
+ // Terminal states are final
233
+ const terminalStates = [
234
+ PaymentStatus.SUCCESS,
235
+ PaymentStatus.FAILED,
236
+ PaymentStatus.EXPIRED,
237
+ PaymentStatus.CANCELLED
238
+ ];
239
+
240
+ if (terminalStates.includes(session.status)) {
241
+ return session; // already terminal, idempotent
242
+ }
243
+
244
+ // The state machine is enforced here rather than merely documented: without this,
245
+ // any caller could move a live session straight to SUCCESS and bypass verification.
246
+ if (!TERMINAL_STATES.includes(targetStatus)) {
247
+ throw new Error(`Not a terminal target status: ${targetStatus}`);
248
+ }
249
+ if (!isValidTransition(session.status, targetStatus)) {
250
+ throw new Error(`Illegal payment state transition: cannot move from '${session.status}' to '${targetStatus}'.`);
251
+ }
252
+
253
+ const now = new Date().toISOString();
254
+ const attributedObsId = options.attributedObservationId || session.attributedObservationId || null;
255
+
256
+ let metadata = session.metadata || {};
257
+ if (typeof metadata === 'string') {
258
+ try { metadata = JSON.parse(metadata); } catch { metadata = {}; }
259
+ }
260
+ if (options.reason) {
261
+ metadata = { ...metadata, cancelledReason: options.reason };
262
+ }
263
+ if (options.replacedByDeviceId) {
264
+ metadata = { ...metadata, replacedByDeviceId: options.replacedByDeviceId };
265
+ }
266
+ if (options.replacedBySessionId) {
267
+ metadata = { ...metadata, replacedBySessionId: options.replacedBySessionId };
268
+ }
269
+
270
+ this.db.execute(`
271
+ UPDATE payment_sessions
272
+ SET status = ?, completed_at = ?, attributed_observation_id = ?, metadata = ?, updated_at = ?
273
+ WHERE id = ?
274
+ `, [targetStatus, now, attributedObsId, JSON.stringify(metadata), now, sessionId]);
275
+
276
+ // Release slot on receiving account if this session owned it
277
+ if (session.receivingAccountId) {
278
+ this.db.execute(`
279
+ UPDATE payment_receiving_accounts
280
+ SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
281
+ WHERE id = ? AND (current_active_session_id = ? OR current_active_session_id IS NULL)
282
+ `, [now, session.receivingAccountId, sessionId]);
283
+ }
284
+
285
+ return this.findById(sessionId);
286
+ });
287
+ }
288
+
289
+ findCurrentActiveOrProcessing(receivingAccountId) {
290
+ const row = this.db.queryOne(`
291
+ SELECT * FROM payment_sessions
292
+ WHERE receiving_account_id = ? AND status IN (?, ?)
293
+ LIMIT 1
294
+ `, [receivingAccountId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
295
+
296
+ if (!row) return null;
297
+ const now = new Date();
298
+ const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
299
+ (row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
300
+ if (isStale) {
301
+ const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
302
+ this.transitionToTerminal(row.id, terminalStatus);
303
+ return null;
304
+ }
305
+ return this.mapRow(row);
306
+ }
307
+
308
+ findCurrentActiveOrProcessingForMerchant(merchantId) {
309
+ if (!merchantId) return null;
310
+ const row = this.db.queryOne(`
311
+ SELECT * FROM payment_sessions
312
+ WHERE merchant_id = ? AND status IN (?, ?)
313
+ ORDER BY created_at DESC
314
+ LIMIT 1
315
+ `, [merchantId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING]);
316
+
317
+ if (!row) return null;
318
+ const now = new Date();
319
+ const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
320
+ (row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
321
+ if (isStale) {
322
+ const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
323
+ this.transitionToTerminal(row.id, terminalStatus);
324
+ return null;
325
+ }
326
+ return this.mapRow(row);
327
+ }
328
+
329
+ findActiveForCustomer(customerId, excludeSessionId = null) {
330
+ if (!customerId) return null;
331
+ let query = `
332
+ SELECT * FROM payment_sessions
333
+ WHERE customer_id = ? AND status IN (?, ?, ?)
334
+ `;
335
+ const params = [customerId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
336
+ if (excludeSessionId) {
337
+ query += ` AND id != ? `;
338
+ params.push(excludeSessionId);
339
+ }
340
+ query += ` ORDER BY created_at DESC LIMIT 1 `;
341
+ const row = this.db.queryOne(query, params);
342
+
343
+ if (!row) return null;
344
+ const now = new Date();
345
+ const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
346
+ (row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
347
+ if (isStale) {
348
+ const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
349
+ this.transitionToTerminal(row.id, terminalStatus);
350
+ return null;
351
+ }
352
+ return this.mapRow(row);
353
+ }
354
+
355
+ findActiveForDevice(deviceId, excludeSessionId = null) {
356
+ if (!deviceId) return null;
357
+ let query = `
358
+ SELECT * FROM payment_sessions
359
+ WHERE device_id = ? AND status IN (?, ?, ?)
360
+ `;
361
+ const params = [deviceId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
362
+ if (excludeSessionId) {
363
+ query += ` AND id != ? `;
364
+ params.push(excludeSessionId);
365
+ }
366
+ query += ` ORDER BY created_at DESC LIMIT 1 `;
367
+ const row = this.db.queryOne(query, params);
368
+
369
+ if (!row) return null;
370
+ const now = new Date();
371
+ const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
372
+ (row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
373
+ if (isStale) {
374
+ const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
375
+ this.transitionToTerminal(row.id, terminalStatus);
376
+ return null;
377
+ }
378
+ return this.mapRow(row);
379
+ }
380
+
381
+ findConflictingActiveSession({ customerId = null, deviceId = null, excludeSessionId = null }) {
382
+ if (!customerId && !deviceId) return null;
383
+ let query = `
384
+ SELECT * FROM payment_sessions
385
+ WHERE status IN (?, ?, ?)
386
+ `;
387
+ const params = [PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED];
388
+ if (excludeSessionId) {
389
+ query += ` AND id != ? `;
390
+ params.push(excludeSessionId);
391
+ }
392
+ if (customerId && deviceId) {
393
+ query += ` AND (customer_id = ? OR device_id = ?) `;
394
+ params.push(customerId, deviceId);
395
+ } else if (customerId) {
396
+ query += ` AND customer_id = ? `;
397
+ params.push(customerId);
398
+ } else {
399
+ query += ` AND device_id = ? `;
400
+ params.push(deviceId);
401
+ }
402
+ query += ` ORDER BY created_at DESC LIMIT 1 `;
403
+ const row = this.db.queryOne(query, params);
404
+
405
+ if (!row) return null;
406
+ const now = new Date();
407
+ const isStale = (row.status === PaymentStatus.ACTIVE && row.expires_at && new Date(row.expires_at) <= now) ||
408
+ (row.status === PaymentStatus.PROCESSING && row.processing_expires_at && new Date(row.processing_expires_at) <= now);
409
+ if (isStale) {
410
+ const terminalStatus = row.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
411
+ this.transitionToTerminal(row.id, terminalStatus);
412
+ return null;
413
+ }
414
+ return this.mapRow(row);
415
+ }
416
+
417
+ findAllActiveOrPendingForCustomer(customerId) {
418
+ if (!customerId) return [];
419
+ const rows = this.db.query(`
420
+ SELECT * FROM payment_sessions
421
+ WHERE customer_id = ? AND status IN (?, ?, ?, ?)
422
+ ORDER BY created_at DESC
423
+ `, [customerId, PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED, PaymentStatus.INITIATED]);
424
+ return rows.map(r => this.mapRow(r));
425
+ }
426
+
427
+ findQueuedSessions(receivingAccountId) {
428
+ const rows = this.db.query(`
429
+ SELECT * FROM payment_sessions
430
+ WHERE receiving_account_id = ? AND status = ?
431
+ ORDER BY queue_position ASC, created_at ASC
432
+ `, [receivingAccountId, PaymentStatus.QUEUED]);
433
+
434
+ return rows.map(r => this.mapRow(r));
435
+ }
436
+
437
+ getNextQueuedSession(receivingAccountId) {
438
+ const row = this.db.queryOne(`
439
+ SELECT * FROM payment_sessions
440
+ WHERE receiving_account_id = ? AND status = ?
441
+ ORDER BY queue_position ASC, created_at ASC
442
+ LIMIT 1
443
+ `, [receivingAccountId, PaymentStatus.QUEUED]);
444
+
445
+ if (!row) return null;
446
+ return this.mapRow(row);
447
+ }
448
+
449
+ /**
450
+ * Re-indexes remaining queued sessions after a promotion.
451
+ */
452
+ reindexQueue(receivingAccountId) {
453
+ const queued = this.findQueuedSessions(receivingAccountId);
454
+ let pos = 1;
455
+ for (const session of queued) {
456
+ this.db.execute(`
457
+ UPDATE payment_sessions SET queue_position = ?, updated_at = ?
458
+ WHERE id = ?
459
+ `, [pos++, new Date().toISOString(), session.id]);
460
+ }
461
+ }
462
+
463
+ findRecentlyExpiredOrFailed(receivingAccountId, sinceDate) {
464
+ const rows = this.db.query(`
465
+ SELECT * FROM payment_sessions
466
+ WHERE receiving_account_id = ?
467
+ AND status IN (?, ?, ?)
468
+ AND updated_at >= ?
469
+ ORDER BY updated_at DESC
470
+ LIMIT 20
471
+ `, [receivingAccountId, PaymentStatus.EXPIRED, PaymentStatus.FAILED, PaymentStatus.CANCELLED, sinceDate.toISOString()]);
472
+
473
+ return rows.map(r => this.mapRow(r));
474
+ }
475
+
476
+ /**
477
+ * Records observation confirmation, calculates 2-level verification tier (SMS + Gmail),
478
+ * updates the verification tag, and transitions to SUCCESS if level >= 1 and shouldFinalize is true.
479
+ * @param {string} sessionId
480
+ * @param {string} source E.g. 'GMAIL', 'GOOGLE_MESSAGES'
481
+ * @param {string} observationId
482
+ * @param {{ finalizeSuccess?: boolean }} [options={}]
483
+ * @returns {{ session: any, level: number, sources: string[], tag: string, isFirstSuccess: boolean }}
484
+ */
485
+ recordVerificationSource(sessionId, source, observationId, options = {}) {
486
+ return this.db.transaction(() => {
487
+ const session = this.findById(sessionId);
488
+ if (!session) throw new Error(`Payment session not found: ${sessionId}`);
489
+
490
+ // A dead outcome is final. recordVerificationSource is called from the attribution engine
491
+ // on previously attributed sessions with no status check, so without this guard an
492
+ // out-of-window or duplicated observation flips an EXPIRED/FAILED/CANCELLED session back
493
+ // to SUCCESS and emits a webhook for a payment that was never settled.
494
+ // An already-SUCCESS session is NOT blocked: recording a second channel there is the
495
+ // legitimate SMS+Gmail verification upgrade, which must not re-emit SUCCESS.
496
+ const DEAD_STATES = [
497
+ PaymentStatus.FAILED,
498
+ PaymentStatus.EXPIRED,
499
+ PaymentStatus.CANCELLED
500
+ ];
501
+ if (DEAD_STATES.includes(session.status)) {
502
+ return {
503
+ session,
504
+ level: session.verificationLevel || 0,
505
+ sources: session.verificationSources || [],
506
+ tag: session.verificationTag || 'UNVERIFIED',
507
+ isFirstSuccess: false
508
+ };
509
+ }
510
+
511
+ // Read current verification sources
512
+ const sourcesSet = new Set(session.verificationSources || []);
513
+ sourcesSet.add(source);
514
+ const sources = Array.from(sourcesSet);
515
+
516
+ const level = Math.min(2, sources.length); // 1 or 2 (2-Layer Engine: SMS + Gmail)
517
+ const sourceLabels = sources.map(s => {
518
+ if (s === 'GOOGLE_MESSAGES') return 'SMS';
519
+ if (s === 'GMAIL') return 'Gmail';
520
+ return s;
521
+ });
522
+
523
+ let tag = `Single Verification (${sourceLabels.join(', ')})`;
524
+ if (level >= 2) {
525
+ tag = `Dual Verification (${sourceLabels.join(' + ')})`;
526
+ }
527
+
528
+ const shouldFinalize = options.finalizeSuccess !== false;
529
+ const wantsSuccess = shouldFinalize && level >= 1;
530
+ if (wantsSuccess && session.status !== PaymentStatus.SUCCESS && !isValidTransition(session.status, PaymentStatus.SUCCESS)) {
531
+ throw new Error(`Illegal payment state transition: cannot move from '${session.status}' to 'SUCCESS'.`);
532
+ }
533
+ const isFirstSuccess = wantsSuccess && session.status !== PaymentStatus.SUCCESS;
534
+ const targetStatus = wantsSuccess ? PaymentStatus.SUCCESS : session.status;
535
+ const now = new Date().toISOString();
536
+
537
+ this.db.execute(`
538
+ UPDATE payment_sessions
539
+ SET status = ?,
540
+ verification_level = ?,
541
+ verification_sources = ?,
542
+ verification_tag = ?,
543
+ attributed_observation_id = COALESCE(attributed_observation_id, ?),
544
+ completed_at = COALESCE(completed_at, ?),
545
+ updated_at = ?
546
+ WHERE id = ?
547
+ `, [
548
+ targetStatus,
549
+ level,
550
+ JSON.stringify(sources),
551
+ tag,
552
+ observationId,
553
+ targetStatus === PaymentStatus.SUCCESS ? now : null,
554
+ now,
555
+ sessionId
556
+ ]);
557
+
558
+ // If this transition made it terminal SUCCESS, release slot on receiving account
559
+ if (targetStatus === PaymentStatus.SUCCESS && session.receivingAccountId) {
560
+ this.db.execute(`
561
+ UPDATE payment_receiving_accounts
562
+ SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
563
+ WHERE id = ? AND current_active_session_id = ?
564
+ `, [now, session.receivingAccountId, sessionId]);
565
+ }
566
+
567
+ const updated = this.findById(sessionId);
568
+ return {
569
+ session: updated,
570
+ level,
571
+ sources,
572
+ tag,
573
+ isFirstSuccess
574
+ };
575
+ });
576
+ }
577
+
578
+ bindDevice(sessionId, deviceId) {
579
+ const now = new Date().toISOString();
580
+ this.db.execute(
581
+ 'UPDATE payment_sessions SET device_id = ?, updated_at = ? WHERE id = ?',
582
+ [deviceId, now, sessionId]
583
+ );
584
+ return this.findById(sessionId);
585
+ }
586
+
587
+ mapRow(row) {
588
+ return {
589
+ id: row.id,
590
+ merchantId: row.merchant_id,
591
+ customerId: row.customer_id,
592
+ receivingAccountId: row.receiving_account_id,
593
+ orderId: row.order_id,
594
+ attemptId: row.attempt_id || row.id,
595
+ amountPaise: BigInt(row.amount_paise),
596
+ currency: row.currency,
597
+ method: row.method,
598
+ status: row.status,
599
+ queuePosition: row.queue_position,
600
+ slotAcquiredAt: row.slot_acquired_at ? new Date(row.slot_acquired_at) : null,
601
+ expiresAt: row.expires_at ? new Date(row.expires_at) : null,
602
+ processingStartedAt: row.processing_started_at ? new Date(row.processing_started_at) : null,
603
+ processingExpiresAt: row.processing_expires_at ? new Date(row.processing_expires_at) : null,
604
+ completedAt: row.completed_at ? new Date(row.completed_at) : null,
605
+ returnUrl: row.return_url,
606
+ attributedObservationId: row.attributed_observation_id,
607
+ idempotencyKey: row.idempotency_key,
608
+ deviceId: row.device_id || null,
609
+ verificationLevel: row.verification_level !== undefined && row.verification_level !== null ? Number(row.verification_level) : 0,
610
+ verificationSources: JSON.parse(row.verification_sources || '[]'),
611
+ verificationTag: row.verification_tag || 'UNVERIFIED',
612
+ metadata: JSON.parse(row.metadata || '{}'),
613
+ createdAt: new Date(row.created_at),
614
+ updatedAt: new Date(row.updated_at)
615
+ };
616
+ }
617
+ }
618
+