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,855 @@
1
+ import { PaymentStatus, ObservationAttributionStatus, TicketStatus, SenderType, isTerminalStatus } from '../common/index.js';
2
+
3
+ export class AttributionEngine {
4
+ /**
5
+ * @param {{
6
+ * sessionRepo: import('../db/index.js').PaymentSessionRepository,
7
+ * obsRepo: import('../db/index.js').ObservationRepository,
8
+ * ticketRepo?: import('../db/index.js').SupportTicketRepository,
9
+ * auditRepo?: import('../db/index.js').AuditLogRepository,
10
+ * eventEmitter?: { emit: (event: string, payload: any) => void }
11
+ * }} options
12
+ */
13
+ constructor(options) {
14
+ this.sessionRepo = options.sessionRepo;
15
+ this.obsRepo = options.obsRepo;
16
+ this.ticketRepo = options.ticketRepo;
17
+ this.auditRepo = options.auditRepo;
18
+ this.eventEmitter = options.eventEmitter || { emit: () => {} };
19
+ const isTestExecution = process.env.NODE_ENV === 'test' ||
20
+ Boolean(process.env.NODE_TEST_CONTEXT) ||
21
+ (process.execArgv && process.execArgv.some(a => a.includes('test'))) ||
22
+ (process.argv && process.argv.some(a => a.includes('test')));
23
+
24
+ this.gracePeriodMs = options.gracePeriodMs !== undefined
25
+ ? options.gracePeriodMs
26
+ : (process.env.PAYMENT_GRACE_PERIOD_MS !== undefined
27
+ ? Number(process.env.PAYMENT_GRACE_PERIOD_MS)
28
+ : (isTestExecution ? 0 : 5000));
29
+ /** @type {Map<string, NodeJS.Timeout>} */
30
+ this.pendingGraceTimers = new Map();
31
+ }
32
+
33
+ destroy() {
34
+ for (const timer of this.pendingGraceTimers.values()) {
35
+ clearTimeout(timer);
36
+ }
37
+ this.pendingGraceTimers.clear();
38
+ }
39
+
40
+ /**
41
+ * Processes a normalized canonical observation through the attribution pipeline.
42
+ * @param {ReturnType<typeof import('./normalizer.js').ObservationNormalizer.normalize>} normalizedObs
43
+ * @returns {{
44
+ * attributed: boolean,
45
+ * duplicate: boolean,
46
+ * lateMatch: boolean,
47
+ * paymentSessionId?: string,
48
+ * observationId: string,
49
+ * outcomeStatus: string,
50
+ * verificationLevel?: number,
51
+ * verificationTag?: string,
52
+ * verificationSources?: string[]
53
+ * }}
54
+ */
55
+ processObservation(normalizedObs) {
56
+ // Tier 1: Check fingerprint for deduplication
57
+ const existing = this.obsRepo.findByFingerprint(normalizedObs.fingerprintHash);
58
+ if (existing) {
59
+ // If observation came from the SAME source, drop as redundant duplicate
60
+ if (existing.source === normalizedObs.source) {
61
+ if (this.auditRepo) {
62
+ this.auditRepo.log({
63
+ entityType: 'PAYMENT_OBSERVATION',
64
+ entityId: existing.id,
65
+ action: 'DUPLICATE_OBSERVATION_DROPPED',
66
+ actorType: 'OBSERVER',
67
+ details: { fingerprintHash: normalizedObs.fingerprintHash, source: normalizedObs.source }
68
+ });
69
+ }
70
+ return {
71
+ attributed: false,
72
+ duplicate: true,
73
+ lateMatch: false,
74
+ paymentSessionId: existing.attributedPaymentId || undefined,
75
+ observationId: existing.id,
76
+ outcomeStatus: ObservationAttributionStatus.DUPLICATE_IGNORED
77
+ };
78
+ }
79
+
80
+ // If observation came from a NEW, DIFFERENT channel and payment was already attributed:
81
+ // Upgrade verification tier to Level 2 (Dual Verification)!
82
+ if (existing.attributedPaymentId) {
83
+ const targetSession = this.sessionRepo.findById(existing.attributedPaymentId);
84
+ if (targetSession && isTerminalStatus(targetSession.status) && targetSession.status !== PaymentStatus.SUCCESS) {
85
+ const savedObs = this.obsRepo.create(normalizedObs);
86
+ this.obsRepo.markLateUnmatched(savedObs.id);
87
+ if (this.auditRepo) {
88
+ this.auditRepo.log({
89
+ merchantId: targetSession.merchantId,
90
+ entityType: 'PAYMENT_SESSION',
91
+ entityId: targetSession.id,
92
+ action: 'LATE_OBSERVATION_DETECTED',
93
+ actorType: 'SYSTEM',
94
+ details: {
95
+ observationId: savedObs.id,
96
+ source: normalizedObs.source,
97
+ sessionStatus: targetSession.status
98
+ }
99
+ });
100
+ }
101
+ if (this.ticketRepo && targetSession.customerId) {
102
+ this.ticketRepo.createTicket({
103
+ merchantId: targetSession.merchantId,
104
+ customerId: targetSession.customerId,
105
+ paymentId: targetSession.id,
106
+ subject: `[LATE PAYMENT DETECTED] Order #${targetSession.orderId}`,
107
+ status: TicketStatus.OPEN,
108
+ initialMessage: {
109
+ senderType: SenderType.ADMIN,
110
+ senderId: targetSession.merchantId,
111
+ message: `A delayed payment observation of ${normalizedObs.amountPaise / 100n} INR was detected after session ${targetSession.id} ended with status ${targetSession.status}. Flagged for manual merchant reconciliation.`
112
+ }
113
+ });
114
+ }
115
+ this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
116
+ paymentId: targetSession.id,
117
+ orderId: targetSession.orderId,
118
+ merchantId: targetSession.merchantId,
119
+ amountPaise: normalizedObs.amountPaise,
120
+ observationId: savedObs.id
121
+ });
122
+ return {
123
+ attributed: false,
124
+ duplicate: false,
125
+ lateMatch: true,
126
+ paymentSessionId: targetSession.id,
127
+ observationId: savedObs.id,
128
+ outcomeStatus: ObservationAttributionStatus.LATE_UNMATCHED
129
+ };
130
+ }
131
+
132
+ // Cancel pending 5-second grace timer if any
133
+ const pendingTimer = this.pendingGraceTimers.get(existing.attributedPaymentId);
134
+ if (pendingTimer) {
135
+ clearTimeout(pendingTimer);
136
+ this.pendingGraceTimers.delete(existing.attributedPaymentId);
137
+ }
138
+
139
+ const savedObs = this.obsRepo.create(normalizedObs);
140
+ let verifyResult;
141
+ try {
142
+ verifyResult = this.sessionRepo.recordVerificationSource(
143
+ existing.attributedPaymentId,
144
+ normalizedObs.source,
145
+ savedObs.id,
146
+ { finalizeSuccess: true }
147
+ );
148
+ if (existing.bankReferenceNumber && savedObs.bankReferenceNumber && String(existing.bankReferenceNumber).trim() === String(savedObs.bankReferenceNumber).trim()) {
149
+ this.obsRepo.db.execute(`
150
+ UPDATE payment_observations
151
+ SET attribution_status = ?
152
+ WHERE id = ?
153
+ `, [ObservationAttributionStatus.ATTRIBUTED, savedObs.id]);
154
+ } else {
155
+ this.obsRepo.markAttributed(savedObs.id, existing.attributedPaymentId);
156
+ }
157
+ } catch (dbErr) {
158
+ const errMsg = dbErr?.message || '';
159
+ if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
160
+ return {
161
+ attributed: false,
162
+ duplicate: true,
163
+ lateMatch: false,
164
+ observationId: savedObs.id,
165
+ outcomeStatus: 'REFERENCE_ALREADY_USED'
166
+ };
167
+ }
168
+ throw dbErr;
169
+ }
170
+
171
+ if (this.auditRepo) {
172
+ this.auditRepo.log({
173
+ entityType: 'PAYMENT_SESSION',
174
+ entityId: existing.attributedPaymentId,
175
+ action: 'PAYMENT_VERIFICATION_UPGRADED',
176
+ actorType: 'SYSTEM',
177
+ details: {
178
+ source: normalizedObs.source,
179
+ level: verifyResult.level,
180
+ tag: verifyResult.tag,
181
+ sources: verifyResult.sources
182
+ }
183
+ });
184
+ }
185
+
186
+ if (verifyResult.isFirstSuccess) {
187
+ this.eventEmitter.emit('PAYMENT.SUCCESS', {
188
+ paymentId: existing.attributedPaymentId,
189
+ orderId: verifyResult.session.orderId,
190
+ merchantId: verifyResult.session.merchantId,
191
+ amountPaise: verifyResult.session.amountPaise,
192
+ observationId: savedObs.id,
193
+ verificationLevel: verifyResult.level,
194
+ verificationTag: verifyResult.tag,
195
+ sources: verifyResult.sources
196
+ });
197
+ }
198
+
199
+ this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
200
+ paymentId: existing.attributedPaymentId,
201
+ verificationLevel: verifyResult.level,
202
+ verificationTag: verifyResult.tag,
203
+ sources: verifyResult.sources
204
+ });
205
+
206
+ return {
207
+ attributed: true,
208
+ duplicate: false,
209
+ lateMatch: false,
210
+ paymentSessionId: existing.attributedPaymentId,
211
+ observationId: savedObs.id,
212
+ outcomeStatus: PaymentStatus.SUCCESS,
213
+ verificationLevel: verifyResult.level,
214
+ verificationTag: verifyResult.tag,
215
+ verificationSources: verifyResult.sources
216
+ };
217
+ }
218
+ }
219
+
220
+ // Persist new observation as UNATTRIBUTED first
221
+ const savedObs = this.obsRepo.create(normalizedObs);
222
+
223
+ // Tier 2: Check currently active session on receiving account
224
+ let currentSession = null;
225
+ if (normalizedObs.rawPayload?.targetSessionId) {
226
+ currentSession = this.sessionRepo.findById(normalizedObs.rawPayload.targetSessionId);
227
+ }
228
+ if (!currentSession || (currentSession.amountPaise !== normalizedObs.amountPaise)) {
229
+ currentSession = this.sessionRepo.findCurrentActiveOrProcessing(normalizedObs.receivingAccountId);
230
+ }
231
+ if (currentSession && currentSession.amountPaise !== normalizedObs.amountPaise) {
232
+ const matchRow = this.sessionRepo.db.queryOne(`
233
+ SELECT * FROM payment_sessions
234
+ WHERE receiving_account_id = ? AND status IN ('ACTIVE', 'PROCESSING') AND amount_paise = ?
235
+ ORDER BY created_at DESC
236
+ LIMIT 1
237
+ `, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise)]);
238
+ if (matchRow) {
239
+ currentSession = this.sessionRepo.mapRow(matchRow);
240
+ }
241
+ }
242
+ if (!currentSession) {
243
+ // Fallback: match the amount, but only inside the receiving account the observer
244
+ // reported. Matching "any ACTIVE session on the gateway with this amount" could settle a
245
+ // different merchant's live payment (cross-merchant hijack), so it is intentionally gone.
246
+ const scopedRow = this.sessionRepo.db.queryOne(`
247
+ SELECT * FROM payment_sessions
248
+ WHERE receiving_account_id = ?
249
+ AND status IN ('ACTIVE', 'PROCESSING')
250
+ AND amount_paise = ?
251
+ ORDER BY created_at DESC
252
+ LIMIT 1
253
+ `, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise)]);
254
+ if (scopedRow) {
255
+ currentSession = this.sessionRepo.mapRow(scopedRow);
256
+ }
257
+ }
258
+
259
+ if (currentSession) {
260
+ // Invariant Check 1: Must be in ACTIVE or PROCESSING status
261
+ const isEligibleStatus = [PaymentStatus.ACTIVE, PaymentStatus.PROCESSING].includes(currentSession.status);
262
+
263
+ // Invariant Check 2: Exact Amount Match (in integer paise)
264
+ const isAmountMatch = currentSession.amountPaise === normalizedObs.amountPaise;
265
+
266
+ // Invariant Check 3: Temporal Window (with 5-minute clock skew tolerance, strictly preventing hijacking of prior expired sessions)
267
+ let isPriorExpiredSession = false;
268
+ if (currentSession.slotAcquiredAt && normalizedObs.observedAt < currentSession.slotAcquiredAt) {
269
+ const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
270
+ const recentExpired = this.sessionRepo.findRecentlyExpiredOrFailed(
271
+ normalizedObs.receivingAccountId,
272
+ thirtyMinutesAgo
273
+ );
274
+ isPriorExpiredSession = recentExpired.some(s => s.amountPaise === normalizedObs.amountPaise);
275
+ }
276
+
277
+ const slotStart = currentSession.slotAcquiredAt
278
+ ? new Date(currentSession.slotAcquiredAt.getTime() - 300000)
279
+ : new Date(currentSession.createdAt.getTime() - 300000);
280
+
281
+ const isTimeValid = !isPriorExpiredSession && (normalizedObs.observedAt >= slotStart);
282
+
283
+ // Invariant Check 0a: Transaction Reference Already Used Check
284
+ let isUtrAlreadyUsed = false;
285
+ const trimmedUtr = normalizedObs.bankReferenceNumber ? String(normalizedObs.bankReferenceNumber).trim() : '';
286
+ if (trimmedUtr.length > 0) {
287
+ const priorUsedSession = this.sessionRepo.db.queryOne(`
288
+ SELECT ps.id, ps.order_id
289
+ FROM payment_sessions ps
290
+ JOIN payment_observations po ON (ps.attributed_observation_id = po.id OR po.attributed_payment_id = ps.id)
291
+ WHERE ps.status NOT IN ('FAILED', 'EXPIRED', 'CANCELLED')
292
+ AND ps.id != ?
293
+ AND po.bank_reference_number = ?
294
+ LIMIT 1
295
+ `, [currentSession.id, trimmedUtr]);
296
+
297
+ if (priorUsedSession) {
298
+ isUtrAlreadyUsed = true;
299
+ if (this.auditRepo) {
300
+ this.auditRepo.log({
301
+ merchantId: currentSession.merchantId,
302
+ entityType: 'PAYMENT_SESSION',
303
+ entityId: currentSession.id,
304
+ action: 'REUSED_UTR_REJECTED',
305
+ actorType: 'SYSTEM',
306
+ details: {
307
+ bankReferenceNumber: trimmedUtr,
308
+ consumedByOrderId: priorUsedSession.order_id,
309
+ consumedBySessionId: priorUsedSession.id
310
+ }
311
+ });
312
+ }
313
+ }
314
+ }
315
+
316
+ // Invariant Check 0b: Check if SMS is from personal numeric phone number (10-15 digits E.164, no letters)
317
+ let isPersonalSms = false;
318
+ const senderStr = (normalizedObs.rawPayload?.sender || normalizedObs.rawPayload?.from || '').toString().trim();
319
+ if ((normalizedObs.source === 'SMS' || normalizedObs.source === 'ANDROID_OBSERVER') && senderStr) {
320
+ const digitsOnly = senderStr.replace(/[\s+-]/g, '');
321
+ if (digitsOnly.length >= 10 && /^\d+$/.test(digitsOnly) && !/[a-zA-Z]/.test(senderStr)) {
322
+ isPersonalSms = true;
323
+ }
324
+ }
325
+
326
+ if (isUtrAlreadyUsed) {
327
+ return {
328
+ attributed: false,
329
+ duplicate: true,
330
+ lateMatch: false,
331
+ observationId: savedObs.id,
332
+ outcomeStatus: 'REFERENCE_ALREADY_USED'
333
+ };
334
+ }
335
+
336
+ if (isPersonalSms) {
337
+ return {
338
+ attributed: false,
339
+ duplicate: false,
340
+ lateMatch: false,
341
+ observationId: savedObs.id,
342
+ outcomeStatus: 'PERSONAL_SMS_REJECTED'
343
+ };
344
+ }
345
+
346
+ if (isEligibleStatus && isAmountMatch && isTimeValid) {
347
+ const wasActive = currentSession.status === PaymentStatus.ACTIVE;
348
+
349
+ // Check if there is already an active grace period awaiting second source for this session
350
+ const pendingGrace = this.pendingGraceTimers.get(currentSession.id);
351
+ if (pendingGrace) {
352
+ clearTimeout(pendingGrace);
353
+ this.pendingGraceTimers.delete(currentSession.id);
354
+
355
+ // Second source confirmed within 5-second grace window -> LEVEL 2 DUAL VERIFICATION!
356
+ let verifyResult;
357
+ try {
358
+ verifyResult = this.sessionRepo.recordVerificationSource(
359
+ currentSession.id,
360
+ normalizedObs.source,
361
+ savedObs.id,
362
+ { finalizeSuccess: true }
363
+ );
364
+
365
+ // Check if primary observation already holds the attributed bank_reference_number
366
+ const primaryObs = currentSession.attributedObservationId
367
+ ? this.obsRepo.findById(currentSession.attributedObservationId)
368
+ : null;
369
+ const sameUtrAsPrimary = Boolean(
370
+ primaryObs &&
371
+ primaryObs.bankReferenceNumber &&
372
+ savedObs.bankReferenceNumber &&
373
+ String(primaryObs.bankReferenceNumber).trim() === String(savedObs.bankReferenceNumber).trim()
374
+ );
375
+
376
+ if (!sameUtrAsPrimary) {
377
+ this.obsRepo.markAttributed(savedObs.id, currentSession.id);
378
+ } else {
379
+ // Secondary dual-verification observation corroborates existing attributed UTR
380
+ this.obsRepo.db.execute(`
381
+ UPDATE payment_observations
382
+ SET attribution_status = ?
383
+ WHERE id = ?
384
+ `, [ObservationAttributionStatus.ATTRIBUTED, savedObs.id]);
385
+ }
386
+ } catch (dbErr) {
387
+ const errMsg = dbErr?.message || '';
388
+ if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
389
+ return {
390
+ attributed: false,
391
+ duplicate: true,
392
+ lateMatch: false,
393
+ observationId: savedObs.id,
394
+ outcomeStatus: 'REFERENCE_ALREADY_USED'
395
+ };
396
+ }
397
+ throw dbErr;
398
+ }
399
+
400
+ if (this.auditRepo) {
401
+ this.auditRepo.log({
402
+ merchantId: currentSession.merchantId,
403
+ entityType: 'PAYMENT_SESSION',
404
+ entityId: currentSession.id,
405
+ action: 'PAYMENT_VERIFICATION_UPGRADED_L2',
406
+ actorType: 'SYSTEM',
407
+ details: {
408
+ observationId: savedObs.id,
409
+ source: normalizedObs.source,
410
+ verificationLevel: 2,
411
+ verificationTag: verifyResult.tag,
412
+ sources: verifyResult.sources
413
+ }
414
+ });
415
+ }
416
+
417
+ this.eventEmitter.emit('PAYMENT.SUCCESS', {
418
+ paymentId: currentSession.id,
419
+ orderId: currentSession.orderId,
420
+ merchantId: currentSession.merchantId,
421
+ amountPaise: currentSession.amountPaise,
422
+ observationId: savedObs.id,
423
+ verificationLevel: 2,
424
+ verificationTag: verifyResult.tag,
425
+ sources: verifyResult.sources
426
+ });
427
+
428
+ this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
429
+ paymentId: currentSession.id,
430
+ orderId: currentSession.orderId,
431
+ verificationLevel: 2,
432
+ verificationTag: verifyResult.tag,
433
+ sources: verifyResult.sources
434
+ });
435
+
436
+ return {
437
+ attributed: true,
438
+ duplicate: false,
439
+ lateMatch: false,
440
+ wasActive,
441
+ paymentSessionId: currentSession.id,
442
+ observationId: savedObs.id,
443
+ outcomeStatus: PaymentStatus.SUCCESS,
444
+ verificationLevel: 2,
445
+ verificationTag: verifyResult.tag,
446
+ verificationSources: verifyResult.sources
447
+ };
448
+ }
449
+
450
+ // FIRST SOURCE CONFIRMATION:
451
+ // Record Level 1 verification source without immediate finalization (start 5-second grace window)
452
+ let verifyResult;
453
+ try {
454
+ verifyResult = this.sessionRepo.recordVerificationSource(
455
+ currentSession.id,
456
+ normalizedObs.source,
457
+ savedObs.id,
458
+ { finalizeSuccess: false }
459
+ );
460
+
461
+ this.obsRepo.markAttributed(savedObs.id, currentSession.id);
462
+ } catch (dbErr) {
463
+ const errMsg = dbErr.message || '';
464
+ if (errMsg.includes('UNIQUE constraint failed') || errMsg.includes('duplicate key value') || errMsg.includes('idx_observations_unique_attributed_bank_ref') || errMsg.includes('idx_payment_sessions_attributed_obs')) {
465
+ if (this.auditRepo) {
466
+ this.auditRepo.log({
467
+ merchantId: currentSession.merchantId,
468
+ entityType: 'PAYMENT_SESSION',
469
+ entityId: currentSession.id,
470
+ action: 'REUSED_UTR_REJECTED',
471
+ actorType: 'SYSTEM',
472
+ details: {
473
+ bankReferenceNumber: trimmedUtr,
474
+ error: 'Concurrent UTR or observation attribution race prevented by database unique constraint.'
475
+ }
476
+ });
477
+ }
478
+ return {
479
+ attributed: false,
480
+ duplicate: true,
481
+ lateMatch: false,
482
+ observationId: savedObs.id,
483
+ outcomeStatus: 'REFERENCE_ALREADY_USED'
484
+ };
485
+ }
486
+ throw dbErr;
487
+ }
488
+
489
+ if (this.auditRepo) {
490
+ this.auditRepo.log({
491
+ merchantId: currentSession.merchantId,
492
+ entityType: 'PAYMENT_SESSION',
493
+ entityId: currentSession.id,
494
+ action: 'PAYMENT_LEVEL1_VERIFIED',
495
+ actorType: 'SYSTEM',
496
+ details: {
497
+ observationId: savedObs.id,
498
+ source: normalizedObs.source,
499
+ amountPaise: normalizedObs.amountPaise.toString(),
500
+ verificationLevel: 1,
501
+ verificationTag: verifyResult.tag,
502
+ sources: verifyResult.sources
503
+ }
504
+ });
505
+ }
506
+
507
+ this.eventEmitter.emit('PAYMENT.LEVEL1_VERIFIED', {
508
+ paymentId: currentSession.id,
509
+ orderId: currentSession.orderId,
510
+ merchantId: currentSession.merchantId,
511
+ amountPaise: currentSession.amountPaise,
512
+ observationId: savedObs.id,
513
+ verificationLevel: 1,
514
+ verificationTag: verifyResult.tag,
515
+ sources: verifyResult.sources
516
+ });
517
+
518
+ // If grace period is 0 (e.g. in instant tests), finalize immediately
519
+ if (this.gracePeriodMs <= 0) {
520
+ const finalResult = this.sessionRepo.recordVerificationSource(
521
+ currentSession.id,
522
+ normalizedObs.source,
523
+ savedObs.id,
524
+ { finalizeSuccess: true }
525
+ );
526
+ this.eventEmitter.emit('PAYMENT.SUCCESS', {
527
+ paymentId: currentSession.id,
528
+ orderId: currentSession.orderId,
529
+ merchantId: currentSession.merchantId,
530
+ amountPaise: currentSession.amountPaise,
531
+ observationId: savedObs.id,
532
+ verificationLevel: finalResult.level,
533
+ verificationTag: finalResult.tag,
534
+ sources: finalResult.sources
535
+ });
536
+
537
+ return {
538
+ attributed: true,
539
+ duplicate: false,
540
+ lateMatch: false,
541
+ wasActive,
542
+ paymentSessionId: currentSession.id,
543
+ observationId: savedObs.id,
544
+ outcomeStatus: PaymentStatus.SUCCESS,
545
+ verificationLevel: finalResult.level,
546
+ verificationTag: finalResult.tag,
547
+ verificationSources: finalResult.sources
548
+ };
549
+ }
550
+
551
+ // Start 5-second grace window awaiting second poller
552
+ const targetSessionId = currentSession.id;
553
+ const targetObsId = savedObs.id;
554
+ const targetSource = normalizedObs.source;
555
+ const targetOrderId = currentSession.orderId;
556
+ const targetMerchantId = currentSession.merchantId;
557
+ const targetAmountPaise = currentSession.amountPaise;
558
+
559
+ const graceTimer = setTimeout(() => {
560
+ this.pendingGraceTimers.delete(targetSessionId);
561
+ // Check if session became terminal during the grace window
562
+ const latestSession = this.sessionRepo.findById(targetSessionId);
563
+ if (!latestSession || (isTerminalStatus(latestSession.status) && latestSession.status !== PaymentStatus.SUCCESS)) {
564
+ const sessionStatus = latestSession ? latestSession.status : 'EXPIRED';
565
+ this.obsRepo.markLateUnmatched(targetObsId);
566
+ if (this.auditRepo) {
567
+ this.auditRepo.log({
568
+ merchantId: targetMerchantId,
569
+ entityType: 'PAYMENT_SESSION',
570
+ entityId: targetSessionId,
571
+ action: 'LATE_OBSERVATION_DETECTED',
572
+ actorType: 'SYSTEM',
573
+ details: {
574
+ observationId: targetObsId,
575
+ source: targetSource,
576
+ sessionStatus
577
+ }
578
+ });
579
+ }
580
+ if (this.ticketRepo && latestSession?.customerId) {
581
+ this.ticketRepo.createTicket({
582
+ merchantId: targetMerchantId,
583
+ customerId: latestSession.customerId,
584
+ paymentId: targetSessionId,
585
+ subject: `[LATE PAYMENT DETECTED] Order #${targetOrderId}`,
586
+ status: TicketStatus.OPEN,
587
+ initialMessage: {
588
+ senderType: SenderType.ADMIN,
589
+ senderId: targetMerchantId,
590
+ message: `A delayed payment observation of ${targetAmountPaise / 100n} INR was detected after session ${targetSessionId} ended with status ${sessionStatus}. Flagged for manual merchant reconciliation.`
591
+ }
592
+ });
593
+ }
594
+ this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
595
+ paymentId: targetSessionId,
596
+ orderId: targetOrderId,
597
+ merchantId: targetMerchantId,
598
+ amountPaise: targetAmountPaise,
599
+ observationId: targetObsId
600
+ });
601
+ return;
602
+ }
603
+
604
+ if (latestSession.status === PaymentStatus.SUCCESS) {
605
+ return;
606
+ }
607
+
608
+ // 5 seconds elapsed without second source: finalize as Level 1
609
+ const finalResult = this.sessionRepo.recordVerificationSource(
610
+ targetSessionId,
611
+ targetSource,
612
+ targetObsId,
613
+ { finalizeSuccess: true }
614
+ );
615
+
616
+ if (this.auditRepo) {
617
+ this.auditRepo.log({
618
+ merchantId: targetMerchantId,
619
+ entityType: 'PAYMENT_SESSION',
620
+ entityId: targetSessionId,
621
+ action: 'PAYMENT_ATTRIBUTED_SUCCESS_L1',
622
+ actorType: 'SYSTEM',
623
+ details: {
624
+ observationId: targetObsId,
625
+ source: targetSource,
626
+ verificationLevel: finalResult.level,
627
+ verificationTag: finalResult.tag
628
+ }
629
+ });
630
+ }
631
+
632
+ this.eventEmitter.emit('PAYMENT.SUCCESS', {
633
+ paymentId: targetSessionId,
634
+ orderId: targetOrderId,
635
+ merchantId: targetMerchantId,
636
+ amountPaise: targetAmountPaise,
637
+ observationId: targetObsId,
638
+ verificationLevel: finalResult.level,
639
+ verificationTag: finalResult.tag,
640
+ sources: finalResult.sources
641
+ });
642
+ }, this.gracePeriodMs);
643
+
644
+ if (graceTimer.unref) graceTimer.unref();
645
+ this.pendingGraceTimers.set(targetSessionId, graceTimer);
646
+
647
+ return {
648
+ attributed: true,
649
+ duplicate: false,
650
+ lateMatch: false,
651
+ wasActive,
652
+ paymentSessionId: currentSession.id,
653
+ observationId: savedObs.id,
654
+ outcomeStatus: 'VERIFYING',
655
+ verificationLevel: 1,
656
+ verificationTag: verifyResult.tag,
657
+ verificationSources: verifyResult.sources
658
+ };
659
+ }
660
+ }
661
+
662
+ // Check if observation confirms a recently successful session on this receiving account (within 60s)
663
+ // Upgrades verification level (e.g. from Level 1 -> Level 2 -> Level 3)
664
+ const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
665
+ const recentSuccessSession = this.sessionRepo.db.queryOne(`
666
+ SELECT * FROM payment_sessions
667
+ WHERE receiving_account_id = ? AND status = 'SUCCESS'
668
+ AND amount_paise = ? AND completed_at >= ?
669
+ ORDER BY completed_at DESC LIMIT 1
670
+ `, [normalizedObs.receivingAccountId, Number(normalizedObs.amountPaise), sixtySecsAgo]);
671
+
672
+ if (recentSuccessSession) {
673
+ // Check if this session's attributed observation had a UTR
674
+ const existingObs = recentSuccessSession.attributed_observation_id
675
+ ? this.obsRepo.findById(recentSuccessSession.attributed_observation_id)
676
+ : null;
677
+
678
+ // If both have UTRs and they do NOT match, this is NOT the same transaction!
679
+ const isUtrConflict = normalizedObs.bankReferenceNumber &&
680
+ existingObs?.bankReferenceNumber &&
681
+ normalizedObs.bankReferenceNumber !== existingObs.bankReferenceNumber;
682
+
683
+ if (isUtrConflict) {
684
+ if (this.auditRepo) {
685
+ this.auditRepo.log({
686
+ merchantId: recentSuccessSession.merchant_id,
687
+ entityType: 'PAYMENT_SESSION',
688
+ entityId: recentSuccessSession.id,
689
+ action: 'DUPLICATE_PAYMENT_UTR_CONFLICT',
690
+ actorType: 'SYSTEM',
691
+ details: {
692
+ observationId: savedObs.id,
693
+ existingUtr: existingObs?.bankReferenceNumber,
694
+ newUtr: normalizedObs.bankReferenceNumber,
695
+ amountPaise: normalizedObs.amountPaise.toString()
696
+ }
697
+ });
698
+ }
699
+ if (this.ticketRepo && recentSuccessSession.customer_id) {
700
+ this.ticketRepo.createTicket({
701
+ merchantId: recentSuccessSession.merchant_id,
702
+ customerId: recentSuccessSession.customer_id,
703
+ paymentId: recentSuccessSession.id,
704
+ subject: `[DUPLICATE PAYMENT DETECTED] Order #${recentSuccessSession.order_id}`,
705
+ status: TicketStatus.OPEN,
706
+ initialMessage: {
707
+ senderType: SenderType.ADMIN,
708
+ senderId: recentSuccessSession.merchant_id,
709
+ message: `A duplicate payment of ${normalizedObs.amountPaise / 100n} INR with reference ${normalizedObs.bankReferenceNumber} was received within 60s of completed order #${recentSuccessSession.order_id} (reference ${existingObs?.bankReferenceNumber || 'N/A'}). Flagged for merchant refund reconciliation.`
710
+ }
711
+ });
712
+ }
713
+ this.eventEmitter.emit('PAYMENT.DUPLICATE_DETECTED', {
714
+ paymentId: recentSuccessSession.id,
715
+ orderId: recentSuccessSession.order_id,
716
+ merchantId: recentSuccessSession.merchant_id,
717
+ observationId: savedObs.id,
718
+ existingUtr: existingObs?.bankReferenceNumber,
719
+ newUtr: normalizedObs.bankReferenceNumber,
720
+ amountPaise: normalizedObs.amountPaise
721
+ });
722
+ }
723
+
724
+ const sessionSlotStart = recentSuccessSession.slot_acquired_at
725
+ ? new Date(new Date(recentSuccessSession.slot_acquired_at).getTime() - 30000)
726
+ : new Date(new Date(recentSuccessSession.created_at).getTime() - 30000);
727
+ const sessionCompleted = recentSuccessSession.completed_at
728
+ ? new Date(new Date(recentSuccessSession.completed_at).getTime() + 30000)
729
+ : new Date();
730
+
731
+ // Temporal check: observation must have been observed during this session's window
732
+ if (!isUtrConflict && normalizedObs.observedAt >= sessionSlotStart && normalizedObs.observedAt <= sessionCompleted) {
733
+ const verifyResult = this.sessionRepo.recordVerificationSource(
734
+ recentSuccessSession.id,
735
+ normalizedObs.source,
736
+ savedObs.id
737
+ );
738
+
739
+ this.obsRepo.markAttributed(savedObs.id, recentSuccessSession.id);
740
+
741
+ if (this.auditRepo) {
742
+ this.auditRepo.log({
743
+ merchantId: recentSuccessSession.merchant_id,
744
+ entityType: 'PAYMENT_SESSION',
745
+ entityId: recentSuccessSession.id,
746
+ action: 'PAYMENT_VERIFICATION_UPGRADED',
747
+ actorType: 'SYSTEM',
748
+ details: {
749
+ observationId: savedObs.id,
750
+ source: normalizedObs.source,
751
+ amountPaise: normalizedObs.amountPaise.toString(),
752
+ verificationLevel: verifyResult.level,
753
+ verificationTag: verifyResult.tag,
754
+ sources: verifyResult.sources
755
+ }
756
+ });
757
+ }
758
+
759
+ this.eventEmitter.emit('PAYMENT.VERIFICATION_UPGRADED', {
760
+ paymentId: recentSuccessSession.id,
761
+ orderId: recentSuccessSession.order_id,
762
+ verificationLevel: verifyResult.level,
763
+ verificationTag: verifyResult.tag,
764
+ sources: verifyResult.sources
765
+ });
766
+
767
+ return {
768
+ attributed: true,
769
+ duplicate: false,
770
+ lateMatch: false,
771
+ paymentSessionId: recentSuccessSession.id,
772
+ observationId: savedObs.id,
773
+ outcomeStatus: PaymentStatus.SUCCESS,
774
+ verificationLevel: verifyResult.level,
775
+ verificationTag: verifyResult.tag,
776
+ verificationSources: verifyResult.sources
777
+ };
778
+ }
779
+ }
780
+
781
+ // Tier 3: Late Observation Handling
782
+ // Check if observation matches a recently expired or failed session on this receiving account (< 30 mins)
783
+ const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
784
+ const recentExpired = this.sessionRepo.findRecentlyExpiredOrFailed(
785
+ normalizedObs.receivingAccountId,
786
+ thirtyMinutesAgo
787
+ );
788
+
789
+ const lateMatchedSession = recentExpired.find(s => s.amountPaise === normalizedObs.amountPaise);
790
+
791
+ if (lateMatchedSession) {
792
+ // Mark observation as LATE_UNMATCHED
793
+ this.obsRepo.markLateUnmatched(savedObs.id);
794
+
795
+ if (this.auditRepo) {
796
+ this.auditRepo.log({
797
+ merchantId: lateMatchedSession.merchantId,
798
+ entityType: 'PAYMENT_SESSION',
799
+ entityId: lateMatchedSession.id,
800
+ action: 'LATE_OBSERVATION_DETECTED',
801
+ actorType: 'SYSTEM',
802
+ details: {
803
+ observationId: savedObs.id,
804
+ source: normalizedObs.source,
805
+ amountPaise: normalizedObs.amountPaise.toString(),
806
+ sessionStatus: lateMatchedSession.status
807
+ }
808
+ });
809
+ }
810
+
811
+ // Automatically create a high-priority reconciliation support ticket if customer exists
812
+ if (this.ticketRepo && lateMatchedSession.customerId) {
813
+ this.ticketRepo.createTicket({
814
+ merchantId: lateMatchedSession.merchantId,
815
+ customerId: lateMatchedSession.customerId,
816
+ paymentId: lateMatchedSession.id,
817
+ subject: `[LATE PAYMENT DETECTED] Order #${lateMatchedSession.orderId}`,
818
+ status: TicketStatus.OPEN,
819
+ initialMessage: {
820
+ senderType: SenderType.ADMIN,
821
+ senderId: lateMatchedSession.merchantId,
822
+ message: `A delayed payment observation of ${normalizedObs.amountPaise / 100n} INR was detected after session ${lateMatchedSession.id} ended with status ${lateMatchedSession.status}. Flagged for manual merchant reconciliation.`
823
+ }
824
+ });
825
+ }
826
+
827
+ // Emit LATE_DETECTED event
828
+ this.eventEmitter.emit('PAYMENT.LATE_DETECTED', {
829
+ paymentId: lateMatchedSession.id,
830
+ orderId: lateMatchedSession.orderId,
831
+ merchantId: lateMatchedSession.merchantId,
832
+ amountPaise: normalizedObs.amountPaise,
833
+ observationId: savedObs.id
834
+ });
835
+
836
+ return {
837
+ attributed: false,
838
+ duplicate: false,
839
+ lateMatch: true,
840
+ paymentSessionId: lateMatchedSession.id,
841
+ observationId: savedObs.id,
842
+ outcomeStatus: ObservationAttributionStatus.LATE_UNMATCHED
843
+ };
844
+ }
845
+
846
+ // Unattributed observation
847
+ return {
848
+ attributed: false,
849
+ duplicate: false,
850
+ lateMatch: false,
851
+ observationId: savedObs.id,
852
+ outcomeStatus: ObservationAttributionStatus.UNATTRIBUTED
853
+ };
854
+ }
855
+ }