elpaypg 0.0.0-stage → 2.0.1

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 +2424 -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,2000 @@
1
+ import { Router } from 'express';
2
+ import { toRupeesString, buildUpiUri, PaymentStatus, PaymentMethod, UPI_PROVIDERS, isInternalServiceRequest, isValidTransition } from '../vendor/common/index.js';
3
+ import { validateSessionDeviceAccess, verifyRegisteredDeviceCredentials } from '../vendor/core/index.js';
4
+ import { isPaymentOwner } from '../auth-guard.js';
5
+
6
+ /**
7
+ * Validates device access to an active payment session.
8
+ * Enforces:
9
+ * 1. If session has no device binding yet, binds to requesting device only if it is registered and presents valid credentials.
10
+ * 2. If session is bound to Device A, any request omitting Device A's credentials or presenting Device B receives DUPLICATE_SESSION_DETECTED (HTTP 409).
11
+ */
12
+ function checkSessionDeviceAccess(session, req, body = null, ctx = null) {
13
+ // If session is already in a terminal state, allow access so terminal status can be rendered
14
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED, PaymentStatus.SUSPICIOUS].includes(session.status)) {
15
+ return { allowed: true, boundDeviceId: session.deviceId, isTerminal: true };
16
+ }
17
+
18
+ // Only a co-located service on loopback holding the shared internal token may bypass device blocking.
19
+ // The `X-Internal-Render` flag on its own is attacker-controlled and proves nothing.
20
+ if (isInternalServiceRequest(req)) {
21
+ return { allowed: true, boundDeviceId: session.deviceId };
22
+ }
23
+
24
+ const requestingDeviceId = req.headers['x-device-id'] ||
25
+ body?.deviceId ||
26
+ (req.query && req.query.deviceId) ||
27
+ null;
28
+ const deviceToken = req.headers['x-device-token'] ||
29
+ body?.deviceToken ||
30
+ (req.query && req.query.deviceToken) ||
31
+ null;
32
+ const deviceSignature = req.headers['x-device-signature'] ||
33
+ body?.deviceSignature ||
34
+ null;
35
+ const signaturePayload = req.headers['x-device-payload'] ||
36
+ body?.signaturePayload ||
37
+ null;
38
+
39
+ // If session does not have a device binding yet:
40
+ if (!session.deviceId) {
41
+ if (requestingDeviceId) {
42
+ const credCheck = verifyRegisteredDeviceCredentials({
43
+ deviceId: requestingDeviceId,
44
+ deviceToken,
45
+ deviceSignature,
46
+ signaturePayload,
47
+ deviceRepo: ctx?.deviceRepo
48
+ });
49
+ if (!credCheck.valid) {
50
+ return {
51
+ allowed: false,
52
+ statusCode: 409,
53
+ body: {
54
+ error: credCheck.error === 'DEVICE_SIGNATURE_INVALID' ? 'DEVICE_SIGNATURE_INVALID' : 'DUPLICATE_SESSION_DETECTED',
55
+ code: credCheck.error,
56
+ session_id: session.id,
57
+ conflicting_session_id: session.id,
58
+ action: 'END_SESSION_AND_CREATE_NEW',
59
+ message: credCheck.message || 'Unregistered or unverified device identity.',
60
+ boundDeviceId: null,
61
+ requestingDeviceId
62
+ }
63
+ };
64
+ }
65
+ if (ctx?.deviceRepo) {
66
+ ctx.deviceRepo.touch(requestingDeviceId);
67
+ }
68
+ if (ctx?.sessionRepo) {
69
+ try {
70
+ ctx.sessionRepo.bindDevice(session.id, requestingDeviceId);
71
+ session.deviceId = requestingDeviceId;
72
+ } catch (err) {
73
+ console.warn('[checkSessionDeviceAccess] bindDevice error:', err.message);
74
+ }
75
+ }
76
+ }
77
+ return { allowed: true, boundDeviceId: session.deviceId };
78
+ }
79
+
80
+ // Session HAS a bound device:
81
+ // Require matching deviceId AND valid cryptographic credential (token or signature).
82
+ const validation = validateSessionDeviceAccess({
83
+ session,
84
+ requestingDeviceId,
85
+ deviceToken,
86
+ deviceSignature,
87
+ signaturePayload,
88
+ deviceRepo: ctx?.deviceRepo
89
+ });
90
+
91
+ if (!validation.allowed) {
92
+ return {
93
+ allowed: false,
94
+ statusCode: 409,
95
+ body: {
96
+ ...validation,
97
+ conflicting_session_id: session.id
98
+ }
99
+ };
100
+ }
101
+
102
+ if (ctx?.deviceRepo) {
103
+ ctx.deviceRepo.touch(session.deviceId);
104
+ }
105
+ return { allowed: true, boundDeviceId: session.deviceId };
106
+ }
107
+
108
+ /**
109
+ * Allowlist-based sanitizer for customer-facing branding (Issue #23).
110
+ * Prevents internal credentials (gmailConfig, cloudflareTunnelCommand, tokens) from leaking to public checkouts.
111
+ */
112
+ function sanitizePublicBranding(raw = {}) {
113
+ // Strict explicit allowlist to prevent arbitrary internal metadata leaks (CWE-200 / OWASP A01)
114
+ return {
115
+ appName: raw.appName || 'ELPAY PG',
116
+ primaryColor: raw.primaryColor || '#2563eb',
117
+ headerTheme: raw.headerTheme || 'gradient',
118
+ logoUrl: raw.logoUrl || null,
119
+ counterInstructions: raw.counterInstructions || 'Please visit the billing counter and present this screen to the cashier.',
120
+ cardCounterInstructions: raw.cardCounterInstructions || 'Please swipe, insert, or tap your credit or debit card on the store POS terminal at the billing desk.',
121
+ supportEmails: Array.isArray(raw.supportEmails) ? raw.supportEmails : [],
122
+ supportMobiles: Array.isArray(raw.supportMobiles) ? raw.supportMobiles : [],
123
+ privacyPolicyUrl: raw.privacyPolicyUrl || null,
124
+ termsUrl: raw.termsUrl || null,
125
+ sellingMode: raw.sellingMode || 'ONLINE',
126
+ customCss: raw.customCss || null,
127
+ enabledPaymentMethods: Array.isArray(raw.enabledPaymentMethods) ? raw.enabledPaymentMethods : ['UPI'],
128
+ sidebarNotice: raw.sidebarNotice || undefined,
129
+ checkoutNotice: raw.checkoutNotice || undefined,
130
+ footerGuarantee: raw.footerGuarantee || undefined,
131
+ trustBadgeText: raw.trustBadgeText || undefined,
132
+ hideTrustBadge: raw.hideTrustBadge !== undefined ? Boolean(raw.hideTrustBadge) : undefined,
133
+ subbannerText: raw.subbannerText || undefined,
134
+ hideSubbanner: raw.hideSubbanner !== undefined ? Boolean(raw.hideSubbanner) : undefined,
135
+ hidePoweredBy: raw.hidePoweredBy !== undefined ? Boolean(raw.hidePoweredBy) : undefined,
136
+ hideTimeSavingBanner: raw.hideTimeSavingBanner !== undefined ? Boolean(raw.hideTimeSavingBanner) : undefined
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Registers customer checkout portal routes.
142
+ * @param {HttpServer} server
143
+ * @param {{
144
+ * merchantRepo: import('../vendor/db/index.js').MerchantRepository,
145
+ * accountRepo: import('../vendor/db/index.js').ReceivingAccountRepository,
146
+ * sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
147
+ * queueEngine: import('../vendor/core/index.js').QueueEngine,
148
+ * timingEngine: import('../vendor/core/index.js').TimingEngine
149
+ * }} ctx
150
+ */
151
+ /**
152
+ * Determines whether UPI pollers / observers (Android SMS, Google Messages, Gmail) are active.
153
+ * Strict Rule: Healthy (WORKING) ONLY IF:
154
+ * 1. Gmail OAuth is configured AND Gmail observer is actively polling.
155
+ * 2. SMS Gateway is connected (Android app or Google Messages) AND actively polling / reflecting messages.
156
+ * If either is missing, unconfigured, or offline, status is ISSUES with downtime warning.
157
+ */
158
+ async function checkUpiServerHealth(ctx, enabledMethods = [], sellingMode = 'OFFLINE', merchantId = '00000000-0000-0000-0000-000000000001') {
159
+ // 0. Check False Narrative Dev Switch (Active for 10 minutes from enabling time)
160
+ try {
161
+ const merchant = ctx.merchantRepo?.findById ? ctx.merchantRepo.findById(merchantId) : null;
162
+ const falseNarrativeUntil = merchant?.branding?.falseNarrativeUntil;
163
+ const falseNarrativeMode = (merchant?.branding?.falseNarrativeMode || 'GREEN').toUpperCase();
164
+ if (falseNarrativeUntil && Number(falseNarrativeUntil) > Date.now()) {
165
+ const remainingSeconds = Math.max(0, Math.round((Number(falseNarrativeUntil) - Date.now()) / 1000));
166
+ if (falseNarrativeMode === 'GREEN') {
167
+ return {
168
+ status: 'WORKING',
169
+ tier: 'GREEN',
170
+ label: 'Server Is Working',
171
+ isWorking: true,
172
+ reason: 'False Narrative Active (Dev Override: GREEN): UPI servers interpreted as operational',
173
+ smsHealthy: true,
174
+ gmailHealthy: true,
175
+ warning: null,
176
+ recommendedAlternative: null,
177
+ isFalseNarrative: true,
178
+ narrative: 'GREEN',
179
+ falseNarrativeRemainingSeconds: remainingSeconds
180
+ };
181
+ } else if (falseNarrativeMode === 'YELLOW') {
182
+ return {
183
+ status: 'BUSY',
184
+ tier: 'YELLOW',
185
+ label: 'UPI Server Busy',
186
+ isWorking: true,
187
+ reason: 'False Narrative Active (Dev Override: YELLOW): UPI servers experiencing processing delays',
188
+ smsHealthy: true,
189
+ gmailHealthy: false,
190
+ warning: 'Your payment may go through extra processing seconds.',
191
+ recommendedAlternative: null,
192
+ isFalseNarrative: true,
193
+ narrative: 'YELLOW',
194
+ falseNarrativeRemainingSeconds: remainingSeconds
195
+ };
196
+ } else if (falseNarrativeMode === 'RED') {
197
+ return {
198
+ status: 'ISSUES',
199
+ tier: 'RED',
200
+ label: 'Server Facing Issues',
201
+ isWorking: false,
202
+ reason: 'False Narrative Active (Dev Override: RED): UPI servers facing downtime',
203
+ smsHealthy: false,
204
+ gmailHealthy: false,
205
+ warning: 'It is not recommended to pay by UPI right now as the server is facing downtime.',
206
+ recommendedAlternative: null,
207
+ isFalseNarrative: true,
208
+ narrative: 'RED',
209
+ falseNarrativeRemainingSeconds: remainingSeconds
210
+ };
211
+ }
212
+ }
213
+ } catch (err) {}
214
+
215
+ let smsHealthy = false;
216
+ let gmailHealthy = false;
217
+ let smsReason = 'SMS gateway disconnected';
218
+ let gmailReason = 'Gmail OAuth unconfigured';
219
+
220
+ // 1. Check SMS Gateway (Android app or Google Messages)
221
+ try {
222
+ if (ctx.db) {
223
+ const activeAndroid = ctx.db.queryOne(`
224
+ SELECT id, device_name, last_heartbeat_at
225
+ FROM android_devices
226
+ WHERE is_active = 1 AND last_heartbeat_at >= datetime('now', '-5 minutes')
227
+ LIMIT 1
228
+ `);
229
+ if (activeAndroid) {
230
+ smsHealthy = true;
231
+ smsReason = `Android SMS Gateway Active (${activeAndroid.device_name || 'Enrolled Device'})`;
232
+ }
233
+ }
234
+ } catch (err) {}
235
+
236
+ if (!smsHealthy) {
237
+ try {
238
+ const smsPort = Number(process.env.SMS_OBSERVER_PORT || 6002);
239
+ const res = await fetch(`http://127.0.0.1:${smsPort}/api/browser/status`, { signal: AbortSignal.timeout(800) });
240
+ if (res.ok) {
241
+ const data = await res.json();
242
+ const isRecent = data.lastSeenAt && (Date.now() - new Date(data.lastSeenAt).getTime() < 300000);
243
+ if (data.isPaired === true && data.status === 'CONNECTED' && data.mode === 'BACKGROUND' && (data.isPolling === true || isRecent)) {
244
+ smsHealthy = true;
245
+ smsReason = 'Google Messages SMS Gateway active & paired';
246
+ }
247
+ }
248
+ } catch (err) {}
249
+ }
250
+
251
+ // 2. Check Gmail OAuth Configuration & Active Poller
252
+ try {
253
+ const merchant = ctx.merchantRepo?.findById ? ctx.merchantRepo.findById(merchantId) : null;
254
+ const gmailConfig = merchant?.branding?.gmailConfig || {};
255
+ const hasRefreshToken = Boolean(gmailConfig.refreshToken && gmailConfig.refreshToken.trim());
256
+ const hasApiKey = Boolean(gmailConfig.apiKey && gmailConfig.apiKey.trim());
257
+
258
+ if (hasRefreshToken || hasApiKey) {
259
+ let pollerActive = false;
260
+ const gmailPort = Number(process.env.GMAIL_OBSERVER_PORT || 6003);
261
+
262
+ try {
263
+ const res = await fetch(`http://127.0.0.1:${gmailPort}/health`, { signal: AbortSignal.timeout(800) });
264
+ if (res.ok) {
265
+ const data = await res.json();
266
+ if (data.status === 'UP') {
267
+ pollerActive = true;
268
+ }
269
+ }
270
+ } catch (e) {}
271
+
272
+ if (!pollerActive) {
273
+ try {
274
+ const res = await fetch(`http://127.0.0.1:${gmailPort}/api/browser/status`, { signal: AbortSignal.timeout(800) });
275
+ if (res.ok) {
276
+ const data = await res.json();
277
+ if (data.isLoggedIn || data.status === 'CONNECTED' || data.isPolling) {
278
+ pollerActive = true;
279
+ }
280
+ }
281
+ } catch (e) {}
282
+ }
283
+
284
+ if (!pollerActive) {
285
+ try {
286
+ const res = await fetch(`http://127.0.0.1:6099/health`, { signal: AbortSignal.timeout(800) });
287
+ if (res.ok) {
288
+ const data = await res.json();
289
+ if (data.status === 'UP' && (data.isPolling || data.pollerActive !== false)) {
290
+ pollerActive = true;
291
+ }
292
+ }
293
+ } catch (e) {}
294
+ }
295
+
296
+ if (pollerActive || hasRefreshToken) {
297
+ gmailHealthy = true;
298
+ gmailReason = 'Gmail OAuth connected and actively polling';
299
+ } else {
300
+ gmailReason = 'Gmail OAuth configured but observer service is offline';
301
+ }
302
+ } else {
303
+ // Check if browser-based Gmail automator is actively logged in
304
+ try {
305
+ const gmailPort = Number(process.env.GMAIL_OBSERVER_PORT || 6003);
306
+ const res = await fetch(`http://127.0.0.1:${gmailPort}/api/browser/status`, { signal: AbortSignal.timeout(800) });
307
+ if (res.ok) {
308
+ const data = await res.json();
309
+ if (data.isLoggedIn === true && data.status === 'CONNECTED') {
310
+ gmailHealthy = true;
311
+ gmailReason = 'Gmail browser automator logged in';
312
+ }
313
+ }
314
+ } catch (e) {}
315
+
316
+ if (!gmailHealthy) {
317
+ gmailReason = 'Gmail OAuth not configured';
318
+ }
319
+ }
320
+ } catch (err) {
321
+ gmailReason = `Gmail check error: ${err.message}`;
322
+ }
323
+
324
+ // 3-Tier Health Model:
325
+ // Green: Both SMS + Gmail connected -> healthy
326
+ // Yellow: Either SMS OR Gmail connected -> extra processing time
327
+ // Red: Neither connected -> server facing issues
328
+ const hasCash = sellingMode === 'ONLINE'
329
+ ? enabledMethods.includes('COD')
330
+ : enabledMethods.includes('CASH_COUNTER');
331
+
332
+ if (smsHealthy && gmailHealthy) {
333
+ return {
334
+ status: 'WORKING',
335
+ tier: 'GREEN',
336
+ label: 'Server Is Working',
337
+ isWorking: true,
338
+ reason: 'All observers active: Gmail OAuth polling & SMS Gateway connected',
339
+ smsHealthy: true,
340
+ gmailHealthy: true,
341
+ warning: null,
342
+ recommendedAlternative: null
343
+ };
344
+ }
345
+
346
+ if (smsHealthy || gmailHealthy) {
347
+ return {
348
+ status: 'BUSY',
349
+ tier: 'YELLOW',
350
+ label: 'UPI Server Busy',
351
+ isWorking: true,
352
+ reason: smsHealthy ? 'SMS observer active, Gmail observer offline' : 'Gmail observer active, SMS observer offline',
353
+ smsHealthy,
354
+ gmailHealthy,
355
+ warning: 'Your payment may go through extra processing seconds.',
356
+ recommendedAlternative: null
357
+ };
358
+ }
359
+
360
+ return {
361
+ status: 'ISSUES',
362
+ tier: 'RED',
363
+ label: 'Server Facing Issues',
364
+ isWorking: false,
365
+ reason: 'Both Gmail OAuth and SMS Gateway are offline or unconfigured',
366
+ smsHealthy: false,
367
+ gmailHealthy: false,
368
+ warning: 'It is not recommended to pay by UPI right now as the server is facing downtime. We recommend paying by cash instead.',
369
+ recommendedAlternative: hasCash ? 'CASH' : null
370
+ };
371
+ }
372
+
373
+ export function registerCheckoutRoutes(router, ctx) {
374
+ // GET /api/v1/checkout/:id/state
375
+ router.get('/api/v1/checkout/:id/state', async (req, res) => {
376
+ const session = ctx.sessionRepo.findById(req.params.id);
377
+ if (!session) {
378
+ res.status(404).json({ error: `Checkout session not found: ${req.params.id}` });
379
+ return;
380
+ }
381
+
382
+ // Server-side Ban & Fraud Enforcement
383
+ const isCustomerBanned = session.customerId && ctx.customerRepo && ctx.customerRepo.isBanned(session.customerId);
384
+ const isSessionBlocked = session.status === 'BLOCKED' || (session.metadata?.banReason === 'FRAUDULENT_PAYMENT_INJECTION');
385
+
386
+ if (isCustomerBanned || isSessionBlocked) {
387
+ const cust = session.customerId ? ctx.customerRepo.findById(session.customerId) : null;
388
+ res.status(200).json({
389
+ id: session.id,
390
+ orderId: session.orderId,
391
+ amount: toRupeesString(session.amountPaise),
392
+ status: 'BLOCKED',
393
+ isBanned: true,
394
+ fraudDetected: true,
395
+ customerName: cust?.name || 'Customer',
396
+ blockedNotice: {
397
+ title: 'FRAUDULENT ACTIVITY DETECTED',
398
+ subtitle: 'Payment Session Terminated & Account Suspended',
399
+ message: 'Our automated verification engine detected fraudulent payment activity associated with this account. Access has been immediately suspended while our security operations team investigates the issue.',
400
+ investigating: true,
401
+ reason: cust?.banReason || 'FRAUDULENT_PAYMENT_INJECTION'
402
+ }
403
+ });
404
+ return;
405
+ }
406
+
407
+ // Authoritative Server-Side Expiration Enforcer
408
+ const now = Date.now();
409
+ if (session.status === PaymentStatus.ACTIVE && session.expiresAt && session.expiresAt.getTime() <= now) {
410
+ ctx.sessionRepo.transitionToTerminal(session.id, PaymentStatus.EXPIRED);
411
+ session.status = PaymentStatus.EXPIRED;
412
+ if (session.receivingAccountId) {
413
+ ctx.queueEngine.promoteNext(session.receivingAccountId);
414
+ }
415
+ server.broadcastSse(session.id, 'status_change', { status: PaymentStatus.EXPIRED });
416
+ } else if (session.status === PaymentStatus.PROCESSING && session.processingExpiresAt && session.processingExpiresAt.getTime() <= now) {
417
+ ctx.sessionRepo.transitionToTerminal(session.id, PaymentStatus.FAILED);
418
+ session.status = PaymentStatus.FAILED;
419
+ if (session.receivingAccountId) {
420
+ ctx.queueEngine.promoteNext(session.receivingAccountId);
421
+ }
422
+ server.broadcastSse(session.id, 'status_change', { status: PaymentStatus.FAILED });
423
+ }
424
+
425
+ // Terminal session handling: if session is terminal, return terminal state directly (CASE 4)
426
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
427
+ const merchant = ctx.merchantRepo.findById(session.merchantId);
428
+ const branding = sanitizePublicBranding(merchant?.branding || {});
429
+ res.status(200).json({
430
+ id: session.id,
431
+ paymentId: session.id,
432
+ orderId: session.orderId,
433
+ amount: toRupeesString(session.amountPaise),
434
+ amountPaise: session.amountPaise.toString(),
435
+ currency: session.currency,
436
+ status: session.status,
437
+ timeRemainingSeconds: 0,
438
+ isTerminal: true,
439
+ // The return URL belongs to the terminal payload too. The checkout
440
+ // page reads it from the *state* it was last given, and a terminal
441
+ // session is answered by this branch — so a session that ends without
442
+ // it loses the merchant's return address, and the customer is left
443
+ // staring at a success screen with no way back to the store. The
444
+ // field is already public in the non-terminal payload below, so
445
+ // including it here exposes nothing new.
446
+ returnUrl: session.returnUrl,
447
+ branding
448
+ });
449
+ return;
450
+ }
451
+
452
+ // Device-level session binding validation (Section 8 & 9)
453
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
454
+ if (!deviceAccess.allowed) {
455
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
456
+ return;
457
+ }
458
+
459
+ const merchant = ctx.merchantRepo.findById(session.merchantId);
460
+ const snapshot = session.metadata?.receivingAccountSnapshot;
461
+ let receivingAccount = session.receivingAccountId
462
+ ? ctx.accountRepo.findById(session.receivingAccountId)
463
+ : null;
464
+
465
+ if (!receivingAccount && snapshot?.id) {
466
+ receivingAccount = ctx.accountRepo.findById(snapshot.id);
467
+ }
468
+ if (!receivingAccount && session.method === 'UPI') {
469
+ receivingAccount = ctx.accountRepo.findActiveForMerchant(session.merchantId);
470
+ }
471
+
472
+ // Invariant: Prefer immutable session snapshot VPA to prevent live VPA mutations from altering open checkout sessions (Issue #27)
473
+ const payeeVpa = snapshot?.upiId || receivingAccount?.upiId || null;
474
+ const payeeName = snapshot?.payeeName || receivingAccount?.payeeName || merchant?.businessName || 'Merchant Store';
475
+ const isMerchantEnabled = (snapshot?.isMerchantEnabled !== undefined)
476
+ ? Boolean(snapshot.isMerchantEnabled)
477
+ : (receivingAccount ? Boolean(receivingAccount.isMerchantEnabled) : true);
478
+ const upiMode = isMerchantEnabled ? 'MERCHANT' : 'NON_MERCHANT';
479
+
480
+ let timeRemainingSeconds = 0;
481
+ if (session.status === PaymentStatus.ACTIVE && session.expiresAt) {
482
+ timeRemainingSeconds = Math.max(0, Math.ceil((session.expiresAt.getTime() - now) / 1000));
483
+ } else if (session.status === PaymentStatus.PROCESSING && session.processingExpiresAt) {
484
+ timeRemainingSeconds = Math.max(0, Math.ceil((session.processingExpiresAt.getTime() - now) / 1000));
485
+ } else if (session.status === PaymentStatus.QUEUED) {
486
+ timeRemainingSeconds = ctx.queueEngine.calculateEstimatedWaitSeconds(
487
+ session.receivingAccountId,
488
+ session.queuePosition
489
+ );
490
+ }
491
+
492
+ let upiUri = null;
493
+ if (session.status === PaymentStatus.ACTIVE && payeeVpa) {
494
+ try {
495
+ upiUri = buildUpiUri({
496
+ payeeVpa,
497
+ payeeName,
498
+ amountPaise: session.amountPaise,
499
+ transactionRef: session.orderId,
500
+ currency: session.currency || 'INR'
501
+ });
502
+ } catch {
503
+ upiUri = null;
504
+ }
505
+ }
506
+
507
+ const merchantBranding = merchant?.branding || {};
508
+ const isCounterSession = session.method === 'CASH_COUNTER' || session.method === 'CARD_COUNTER';
509
+ const sellingMode = merchantBranding.sellingMode || (isCounterSession ? 'OFFLINE' : 'ONLINE');
510
+ const supportEmails = merchantBranding.supportEmails || (merchant?.contactEmail ? [merchant.contactEmail] : []);
511
+ const supportMobiles = merchantBranding.supportMobiles || (merchant?.contactPhone ? [merchant.contactPhone] : []);
512
+ const privacyPolicyUrl = merchantBranding.privacyPolicyUrl || null;
513
+
514
+ let rawEnabled = merchantBranding.enabledPaymentMethods || (sellingMode === 'ONLINE' ? ['UPI'] : ['UPI', 'CASH_COUNTER', 'CARD_COUNTER']);
515
+ let enabledMethods = ['UPI'];
516
+ if (sellingMode === 'ONLINE') {
517
+ if (rawEnabled.includes('COD')) {
518
+ enabledMethods.push('COD');
519
+ }
520
+ } else {
521
+ if (rawEnabled.includes('CASH_COUNTER')) {
522
+ enabledMethods.push('CASH_COUNTER');
523
+ }
524
+ if (rawEnabled.includes('CARD_COUNTER')) {
525
+ enabledMethods.push('CARD_COUNTER');
526
+ }
527
+ }
528
+
529
+ const branding = sanitizePublicBranding({
530
+ ...merchantBranding,
531
+ sellingMode,
532
+ supportEmails,
533
+ supportMobiles,
534
+ privacyPolicyUrl,
535
+ enabledPaymentMethods: enabledMethods
536
+ });
537
+
538
+ const authHeader = req.headers['authorization'] || '';
539
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : (req.headers['x-customer-token'] || null);
540
+
541
+ // Customer PII is only disclosed to a party that is entitled to it:
542
+ // - the owning merchant (admin/observer credential or X-Merchant-Key),
543
+ // - the co-located checkout renderer,
544
+ // - the customer themselves, presenting their own customer session token.
545
+ // An anonymous holder of a payment id must not receive name/email/phone.
546
+ const isOwnerCaller = isPaymentOwner(req, session, ctx) || isInternalServiceRequest(req);
547
+ const tokenAuth = (bearerToken && ctx.customerSessionRepo) ? ctx.customerSessionRepo.findByToken(bearerToken) : null;
548
+ const isSelfCaller = Boolean(tokenAuth?.customer && session.customerId && tokenAuth.customer.id === session.customerId);
549
+
550
+ let customer = null;
551
+ if (session.customerId && ctx.customerRepo && (isOwnerCaller || isSelfCaller)) {
552
+ const c = ctx.customerRepo.findById(session.customerId);
553
+ if (c) {
554
+ customer = {
555
+ id: c.id,
556
+ name: c.name,
557
+ email: c.email,
558
+ mobileNumber: c.mobileNumber,
559
+ fullName: c.name,
560
+ phoneNumber: c.mobileNumber
561
+ };
562
+ }
563
+ } else if (!customer && tokenAuth?.customer) {
564
+ const c = tokenAuth.customer;
565
+ customer = {
566
+ id: c.id,
567
+ name: c.name,
568
+ email: c.email,
569
+ mobileNumber: c.mobileNumber,
570
+ fullName: c.name,
571
+ phoneNumber: c.mobileNumber
572
+ };
573
+ }
574
+
575
+ // Merchant metadata is merchant-private: only the owner (or the internal renderer)
576
+ // may read it. The cancellation reason is exposed separately for the customer UI.
577
+ const publicMetadata = isOwnerCaller ? (session.metadata || {}) : {};
578
+
579
+ // Single active payment session check (Section 1 & 2)
580
+ const requestingDeviceId = req.headers['x-device-id'] || null;
581
+ let hasActiveSessionConflict = false;
582
+ let conflictingSession = null;
583
+ const conflictingActive = ctx.sessionRepo.findConflictingActiveSession
584
+ ? ctx.sessionRepo.findConflictingActiveSession({
585
+ customerId: session.customerId,
586
+ deviceId: requestingDeviceId || session.deviceId || null,
587
+ excludeSessionId: session.id
588
+ })
589
+ : null;
590
+
591
+ if (conflictingActive && conflictingActive.id !== session.id && [PaymentStatus.INITIATED, PaymentStatus.ACTIVE, PaymentStatus.QUEUED].includes(session.status)) {
592
+ if (conflictingActive.deviceId && (!requestingDeviceId || requestingDeviceId !== conflictingActive.deviceId)) {
593
+ // CASE 3: Active session on another device -> DUPLICATE_SESSION_DETECTED
594
+ res.status(409).json({
595
+ error: 'DUPLICATE_SESSION_DETECTED',
596
+ session_id: session.id,
597
+ conflicting_session_id: conflictingActive.id,
598
+ action: 'END_SESSION_AND_CREATE_NEW',
599
+ message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.',
600
+ boundDeviceId: conflictingActive.deviceId,
601
+ requestingDeviceId
602
+ });
603
+ return;
604
+ }
605
+
606
+ // CASE 2: Active session on same device -> CONTINUE ACTIVE SESSION or END & START NEW
607
+ hasActiveSessionConflict = true;
608
+ conflictingSession = {
609
+ id: conflictingActive.id,
610
+ paymentId: conflictingActive.id,
611
+ orderId: conflictingActive.orderId,
612
+ amount: toRupeesString(conflictingActive.amountPaise),
613
+ status: conflictingActive.status,
614
+ checkoutUrl: `/checkout/${conflictingActive.id}`
615
+ };
616
+ }
617
+
618
+ // Build URI and compute remaining time ONLY if session is already active (e.g. after customer bound or counter active)
619
+ if (session.status === PaymentStatus.ACTIVE) {
620
+ if (payeeVpa && !upiUri) {
621
+ try {
622
+ upiUri = buildUpiUri({
623
+ payeeVpa,
624
+ payeeName,
625
+ amountPaise: session.amountPaise,
626
+ transactionRef: session.orderId,
627
+ currency: session.currency || 'INR'
628
+ });
629
+ } catch {}
630
+ }
631
+ if (session.expiresAt) {
632
+ timeRemainingSeconds = Math.max(0, Math.ceil((session.expiresAt.getTime() - now) / 1000));
633
+ }
634
+ }
635
+
636
+ const upiServerIndicator = await checkUpiServerHealth(ctx, enabledMethods, sellingMode, session.merchantId);
637
+
638
+ res.status(200).json({
639
+ paymentId: session.id,
640
+ orderId: session.orderId,
641
+ merchantName: merchant?.businessName || 'Merchant',
642
+ amount: toRupeesString(session.amountPaise),
643
+ amountPaise: session.amountPaise.toString(),
644
+ currency: session.currency,
645
+ status: session.status,
646
+ reason: session.metadata?.cancelledReason || null,
647
+ cancelledReason: session.metadata?.cancelledReason || null,
648
+ metadata: publicMetadata,
649
+ method: session.method,
650
+ verificationLevel: session.verificationLevel || 0,
651
+ verificationSources: session.verificationSources || [],
652
+ verificationTag: session.verificationTag || 'UNVERIFIED',
653
+ sellingMode,
654
+ enabledMethods,
655
+ customer,
656
+ hasActiveSessionConflict,
657
+ conflictingSession,
658
+ queuePosition: session.queuePosition || 0,
659
+ timeRemainingSeconds,
660
+ expiresAt: session.expiresAt ? session.expiresAt.toISOString() : null,
661
+ serverNow: new Date().toISOString(),
662
+ upiUri,
663
+ payeeVpa,
664
+ payeeName,
665
+ isMerchantEnabled,
666
+ upiMode,
667
+ upiServerIndicator,
668
+ receivingAccountId: receivingAccount?.id || snapshot?.id || null,
669
+ providers: UPI_PROVIDERS,
670
+ canClickPaymentDone: session.status === PaymentStatus.ACTIVE,
671
+ returnUrl: session.returnUrl,
672
+ supportEmails,
673
+ supportMobiles,
674
+ privacyPolicyUrl,
675
+ deviceId: session.deviceId || null,
676
+ branding
677
+ });
678
+ });
679
+
680
+ // GET /api/v1/checkout/server-health
681
+ router.get('/api/v1/checkout/server-health', async (req, res) => {
682
+ const merchantId = req.query?.merchantId || '00000000-0000-0000-0000-000000000001';
683
+ const health = await checkUpiServerHealth(ctx, [], 'OFFLINE', merchantId);
684
+ res.status(200).json(health);
685
+ });
686
+
687
+ // POST /api/v1/checkout/:id/bind-customer
688
+ router.post('/api/v1/checkout/:id/bind-customer', async (req, res) => {
689
+ const body = req.body || {};
690
+ const token = body.token ||
691
+ body.sessionToken ||
692
+ req.headers['x-customer-token'] ||
693
+ ((req.headers['authorization'] || '').startsWith('Bearer ') ? req.headers['authorization'].slice(7).trim() : null);
694
+ const { customerId } = body;
695
+
696
+ const session = ctx.sessionRepo.findById(req.params.id);
697
+ if (!session) {
698
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
699
+ return;
700
+ }
701
+
702
+ // INVARIANT: Payment session identity is IMMUTABLE once bound!
703
+ // Cannot mutate or hijack an active session to another customer identity.
704
+ if (session.customerId && customerId && session.customerId !== customerId) {
705
+ res.status(409).json({
706
+ error: 'Payment session is already bound to another customer identity and cannot be mutated or reassigned.'
707
+ });
708
+ return;
709
+ }
710
+
711
+ // Require a valid customer session token; never accept a bare client-asserted customerId!
712
+ if (!token || typeof token !== 'string' || !ctx.customerSessionRepo) {
713
+ res.status(401).json({ error: 'A valid customer session token is required to bind a customer identity.' });
714
+ return;
715
+ }
716
+
717
+ const authContext = ctx.customerSessionRepo.findByToken(token.trim());
718
+ if (!authContext || !authContext.customer) {
719
+ res.status(401).json({ error: 'Invalid or expired customer session token.' });
720
+ return;
721
+ }
722
+
723
+ const customer = authContext.customer;
724
+ if (customerId && customerId !== customer.id) {
725
+ res.status(401).json({ error: 'Customer ID does not match authenticated session token.' });
726
+ return;
727
+ }
728
+
729
+ // Device-level session binding validation
730
+ const deviceAccess = checkSessionDeviceAccess(session, req, body, ctx);
731
+ if (!deviceAccess.allowed) {
732
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
733
+ return;
734
+ }
735
+ if (deviceAccess.boundDeviceId && ctx.deviceRepo) {
736
+ try { ctx.deviceRepo.associateCustomer(deviceAccess.boundDeviceId, customer.id); } catch {}
737
+ }
738
+
739
+ if (session.customerId && session.customerId !== customer.id) {
740
+ res.status(409).json({
741
+ error: 'Payment session is already bound to another customer identity and cannot be mutated or reassigned.'
742
+ });
743
+ return;
744
+ }
745
+
746
+ // Check for active customer session conflict
747
+ const otherActive = ctx.sessionRepo.findActiveForCustomer
748
+ ? ctx.sessionRepo.findActiveForCustomer(customer.id, req.params.id)
749
+ : null;
750
+
751
+ let hasActiveSessionConflict = false;
752
+ let conflictingSession = null;
753
+ if (otherActive && otherActive.id !== req.params.id) {
754
+ hasActiveSessionConflict = true;
755
+ conflictingSession = {
756
+ id: otherActive.id,
757
+ paymentId: otherActive.id,
758
+ orderId: otherActive.orderId,
759
+ amount: toRupeesString(otherActive.amountPaise),
760
+ status: otherActive.status,
761
+ checkoutUrl: `/checkout/${otherActive.id}`
762
+ };
763
+ }
764
+
765
+ // If already bound to this exact customer and already activated, return current state with conflict status
766
+ if (session.customerId === customer.id && session.status !== PaymentStatus.INITIATED) {
767
+ res.status(200).json({
768
+ success: true,
769
+ status: session.status,
770
+ queuePosition: session.queuePosition || 0,
771
+ expiresAt: session.expiresAt,
772
+ hasActiveSessionConflict,
773
+ conflictingSession,
774
+ customer: {
775
+ id: customer.id,
776
+ name: customer.name,
777
+ email: customer.email,
778
+ mobileNumber: customer.mobileNumber,
779
+ fullName: customer.name,
780
+ phoneNumber: customer.mobileNumber
781
+ }
782
+ });
783
+ return;
784
+ }
785
+
786
+ // Bind customer to payment session
787
+ ctx.db.execute(`
788
+ UPDATE payment_sessions
789
+ SET customer_id = ?, updated_at = ?
790
+ WHERE id = ?
791
+ `, [customer.id, new Date().toISOString(), req.params.id]);
792
+
793
+ // INVARIANT: Single Active Payment Session per Customer
794
+ if (otherActive && otherActive.id !== req.params.id) {
795
+ const requestingDeviceId = req.headers['x-device-id'] || body?.deviceId || null;
796
+ if (otherActive.deviceId && (!requestingDeviceId || requestingDeviceId !== otherActive.deviceId)) {
797
+ // CASE 3: Active session on another device -> DUPLICATE_SESSION_DETECTED
798
+ res.status(409).json({
799
+ error: 'DUPLICATE_SESSION_DETECTED',
800
+ session_id: req.params.id,
801
+ conflicting_session_id: otherActive.id,
802
+ action: 'END_SESSION_AND_CREATE_NEW',
803
+ message: 'This customer already has an active payment session on another device. For security reasons, this session cannot be continued from this device.',
804
+ boundDeviceId: otherActive.deviceId,
805
+ requestingDeviceId
806
+ });
807
+ return;
808
+ }
809
+
810
+ hasActiveSessionConflict = true;
811
+ conflictingSession = {
812
+ id: otherActive.id,
813
+ paymentId: otherActive.id,
814
+ orderId: otherActive.orderId,
815
+ amount: toRupeesString(otherActive.amountPaise),
816
+ status: otherActive.status,
817
+ checkoutUrl: `/checkout/${otherActive.id}`
818
+ };
819
+ }
820
+
821
+ // INVARIANT: When customer logs in or signs up, activate session and start 5-minute timer NOW (unless conflict exists)!
822
+ let queueResult = { status: session.status, queuePosition: session.queuePosition, expiresAt: session.expiresAt };
823
+ const targetAccountId = session.receivingAccountId || ctx.accountRepo?.findActiveForMerchant(session.merchantId)?.id || session.metadata?.receivingAccountSnapshot?.id;
824
+ if (!hasActiveSessionConflict && session.status === PaymentStatus.INITIATED && targetAccountId) {
825
+ queueResult = ctx.queueEngine.enqueueOrActivate(session.id, targetAccountId);
826
+ if (queueResult.expiresAt) {
827
+ ctx.timingEngine.startActiveTimer(session.id, queueResult.expiresAt);
828
+ }
829
+ server.broadcastSse(session.id, 'status_change', {
830
+ status: queueResult.status,
831
+ queuePosition: queueResult.queuePosition,
832
+ expiresAt: queueResult.expiresAt
833
+ });
834
+ server.broadcastSse('admin_counter_events', 'status_change', {
835
+ status: queueResult.status,
836
+ sessionId: session.id,
837
+ orderId: session.orderId,
838
+ amountPaise: session.amountPaise ? session.amountPaise.toString() : '0'
839
+ });
840
+ }
841
+
842
+ if (ctx.auditRepo) {
843
+ ctx.auditRepo.log({
844
+ merchantId: session.merchantId,
845
+ entityType: 'PAYMENT_SESSION',
846
+ entityId: session.id,
847
+ action: 'CUSTOMER_AUTHENTICATED_AND_BOUND',
848
+ actorType: 'CUSTOMER',
849
+ actorId: customer.id,
850
+ details: { email: customer.email, mobileNumber: customer.mobileNumber, sessionStatus: queueResult.status, hasActiveSessionConflict }
851
+ });
852
+ }
853
+
854
+ res.status(200).json({
855
+ success: true,
856
+ status: queueResult.status,
857
+ queuePosition: queueResult.queuePosition,
858
+ expiresAt: queueResult.expiresAt,
859
+ hasActiveSessionConflict,
860
+ conflictingSession,
861
+ customer: {
862
+ id: customer.id,
863
+ name: customer.name,
864
+ email: customer.email,
865
+ mobileNumber: customer.mobileNumber,
866
+ fullName: customer.name,
867
+ phoneNumber: customer.mobileNumber
868
+ }
869
+ });
870
+ });
871
+
872
+ // POST /api/v1/checkout/:id/retry-new-payment - Cancels previous abandoned active session and activates new payment
873
+ router.post('/api/v1/checkout/:id/retry-new-payment', async (req, res) => {
874
+ const session = ctx.sessionRepo.findById(req.params.id);
875
+ if (!session) {
876
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
877
+ return;
878
+ }
879
+
880
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
881
+ if (!deviceAccess.allowed) {
882
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
883
+ return;
884
+ }
885
+
886
+ if (session.status === PaymentStatus.PROCESSING) {
887
+ res.status(409).json({
888
+ error: 'SESSION_PROCESSING',
889
+ message: 'Cannot retry payment while session is in PROCESSING status.'
890
+ });
891
+ return;
892
+ }
893
+
894
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
895
+ res.status(400).json({ error: `Cannot retry payment for terminal session in status '${session.status}'` });
896
+ return;
897
+ }
898
+
899
+ const customerId = session.customerId;
900
+ const requestingDeviceId = req.headers['x-device-id'] || session.deviceId || null;
901
+
902
+ // Never cancel a session that is in PROCESSING (in-flight bank verification).
903
+ const processingConflict = ctx.db.query(`
904
+ SELECT id FROM payment_sessions
905
+ WHERE id != ? AND status = 'PROCESSING'
906
+ AND (
907
+ (? IS NOT NULL AND customer_id = ?)
908
+ OR (? IS NOT NULL AND device_id = ?)
909
+ )
910
+ `, [session.id, customerId, customerId, requestingDeviceId, requestingDeviceId]);
911
+ if (processingConflict.length > 0) {
912
+ res.status(409).json({
913
+ error: 'SESSION_PROCESSING',
914
+ message: 'Another payment session for this customer or device is currently in bank verification (PROCESSING) and cannot be cancelled.'
915
+ });
916
+ return;
917
+ }
918
+
919
+ // Find all conflicting active/queued sessions for this customer OR device
920
+ const conflicting = ctx.db.query(`
921
+ SELECT id, receiving_account_id, status FROM payment_sessions
922
+ WHERE id != ? AND status IN ('ACTIVE', 'QUEUED')
923
+ AND (
924
+ (? IS NOT NULL AND customer_id = ?)
925
+ OR (? IS NOT NULL AND device_id = ?)
926
+ )
927
+ `, [session.id, customerId, customerId, requestingDeviceId, requestingDeviceId]);
928
+
929
+ for (const c of conflicting) {
930
+ ctx.sessionRepo.transitionToTerminal(c.id, PaymentStatus.CANCELLED, {
931
+ reason: 'USER_RETRY_NEW_PAYMENT',
932
+ replacedBySessionId: session.id
933
+ });
934
+ if (ctx.timingEngine) {
935
+ ctx.timingEngine.cancelActiveTimer(c.id);
936
+ if (typeof ctx.timingEngine.clearTimers === 'function') {
937
+ ctx.timingEngine.clearTimers(c.id);
938
+ }
939
+ }
940
+ server.broadcastSse(c.id, 'status_change', {
941
+ status: PaymentStatus.CANCELLED,
942
+ reason: 'USER_RETRY_NEW_PAYMENT'
943
+ });
944
+ }
945
+
946
+ // Target receiving account
947
+ const activeMerchantAcc = ctx.accountRepo ? ctx.accountRepo.findActiveForMerchant(session.merchantId) : null;
948
+ const targetAccountId = activeMerchantAcc?.id ||
949
+ session.receivingAccountId ||
950
+ session.metadata?.receivingAccountSnapshot?.id;
951
+
952
+ if (targetAccountId && session.receivingAccountId !== targetAccountId) {
953
+ ctx.db.execute('UPDATE payment_sessions SET receiving_account_id = ? WHERE id = ?', [targetAccountId, session.id]);
954
+ session.receivingAccountId = targetAccountId;
955
+ }
956
+
957
+ // Now activate current session immediately on the newly freed slot
958
+ let queueResult = { status: session.status, queuePosition: session.queuePosition, expiresAt: session.expiresAt };
959
+ if (targetAccountId) {
960
+ queueResult = ctx.queueEngine.enqueueOrActivate(session.id, targetAccountId);
961
+ if (queueResult.expiresAt) {
962
+ ctx.timingEngine.startActiveTimer(session.id, queueResult.expiresAt);
963
+ }
964
+ server.broadcastSse(session.id, 'status_change', {
965
+ status: queueResult.status,
966
+ queuePosition: queueResult.queuePosition,
967
+ expiresAt: queueResult.expiresAt
968
+ });
969
+ server.broadcastSse('admin_counter_events', 'status_change', {
970
+ status: queueResult.status,
971
+ sessionId: session.id,
972
+ orderId: session.orderId,
973
+ amountPaise: session.amountPaise ? session.amountPaise.toString() : '0'
974
+ });
975
+ ctx.sessionRepo.reindexQueue(targetAccountId);
976
+ }
977
+
978
+ const updated = ctx.sessionRepo.findById(session.id);
979
+ res.status(200).json({
980
+ success: true,
981
+ paymentId: updated.id,
982
+ status: updated.status,
983
+ queuePosition: updated.queuePosition || 0,
984
+ expiresAt: updated.expiresAt,
985
+ message: 'Previous active session cancelled. New payment session activated.'
986
+ });
987
+ });
988
+
989
+ // POST /api/v1/checkout/:id/payment-done
990
+ router.post('/api/v1/checkout/:id/payment-done', async (req, res) => {
991
+ const session = ctx.sessionRepo.findById(req.params.id);
992
+ if (!session) {
993
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
994
+ return;
995
+ }
996
+
997
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
998
+ if (!deviceAccess.allowed) {
999
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1000
+ return;
1001
+ }
1002
+
1003
+ if (session.status === PaymentStatus.PROCESSING) {
1004
+ // Idempotent
1005
+ res.status(200).json({
1006
+ status: PaymentStatus.PROCESSING,
1007
+ message: 'Already in verification processing.'
1008
+ });
1009
+ return;
1010
+ }
1011
+
1012
+ const now = Date.now();
1013
+ if (session.status === PaymentStatus.ACTIVE && session.expiresAt && session.expiresAt.getTime() <= now) {
1014
+ ctx.sessionRepo.transitionToTerminal(session.id, PaymentStatus.EXPIRED);
1015
+ if (session.receivingAccountId) {
1016
+ ctx.queueEngine.promoteNext(session.receivingAccountId);
1017
+ }
1018
+ server.broadcastSse(session.id, 'status_change', { status: PaymentStatus.EXPIRED });
1019
+ res.status(400).json({
1020
+ error: 'Cannot start verification: payment session has EXPIRED.'
1021
+ });
1022
+ return;
1023
+ }
1024
+
1025
+ if (session.status !== PaymentStatus.ACTIVE) {
1026
+ res.status(400).json({
1027
+ error: `Cannot start verification: session is in status '${session.status}', must be 'ACTIVE'.`
1028
+ });
1029
+ return;
1030
+ }
1031
+
1032
+ const updated = ctx.timingEngine.handlePaymentDone(req.params.id);
1033
+
1034
+ // Broadcast SSE update
1035
+ server.broadcastSse(req.params.id, 'status_change', {
1036
+ status: PaymentStatus.PROCESSING,
1037
+ processingExpiresAt: updated.processingExpiresAt
1038
+ });
1039
+ server.broadcastSse('admin_counter_events', 'status_change', {
1040
+ status: PaymentStatus.PROCESSING,
1041
+ sessionId: updated.id,
1042
+ orderId: updated.orderId,
1043
+ amountPaise: updated.amountPaise ? updated.amountPaise.toString() : '0'
1044
+ });
1045
+
1046
+ res.status(200).json({
1047
+ paymentId: updated.id,
1048
+ status: PaymentStatus.PROCESSING,
1049
+ processingExpiresAt: updated.processingExpiresAt
1050
+ });
1051
+ });
1052
+
1053
+ // POST /api/v1/checkout/:id/cancel
1054
+ router.post('/api/v1/checkout/:id/cancel', async (req, res) => {
1055
+ const session = ctx.sessionRepo.findById(req.params.id);
1056
+ if (!session) {
1057
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1058
+ return;
1059
+ }
1060
+
1061
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
1062
+ res.status(200).json({
1063
+ paymentId: session.id,
1064
+ status: session.status
1065
+ });
1066
+ return;
1067
+ }
1068
+
1069
+ if (session.status === PaymentStatus.PROCESSING) {
1070
+ res.status(409).json({
1071
+ error: 'SESSION_PROCESSING',
1072
+ message: 'Cannot cancel a payment session while bank verification is in progress (PROCESSING).'
1073
+ });
1074
+ return;
1075
+ }
1076
+
1077
+ // Cancelling releases the merchant's scarce UPI slot and ends a live payment, so it is
1078
+ // subject to the same device-binding check as the other state-changing checkout actions.
1079
+ // Without this, anyone holding a payment id could grief an in-flight checkout.
1080
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
1081
+ if (!deviceAccess.allowed) {
1082
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1083
+ return;
1084
+ }
1085
+
1086
+ const updated = ctx.timingEngine.handleCancel(req.params.id);
1087
+
1088
+ server.broadcastSse(req.params.id, 'status_change', {
1089
+ status: PaymentStatus.CANCELLED
1090
+ });
1091
+
1092
+ res.status(200).json({
1093
+ paymentId: updated?.id || session.id,
1094
+ status: PaymentStatus.CANCELLED
1095
+ });
1096
+ });
1097
+
1098
+ // POST /api/v1/checkout/:id/select-method (Switches payment method e.g. Cash/Card at Counter, COD, or UPI)
1099
+ router.post('/api/v1/checkout/:id/select-method', async (req, res) => {
1100
+ const body = req.body || {};
1101
+ const { method } = body;
1102
+
1103
+ const session = ctx.sessionRepo.findById(req.params.id);
1104
+ if (!session) {
1105
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1106
+ return;
1107
+ }
1108
+
1109
+ const deviceAccess = checkSessionDeviceAccess(session, req, body, ctx);
1110
+ if (!deviceAccess.allowed) {
1111
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1112
+ return;
1113
+ }
1114
+
1115
+ if (session.status === PaymentStatus.PROCESSING) {
1116
+ res.status(400).json({ error: `Cannot switch payment method while payment is in PROCESSING status: Cannot switch payment method for session in status 'PROCESSING'` });
1117
+ return;
1118
+ }
1119
+
1120
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
1121
+ res.status(400).json({ error: `Cannot switch payment method for session in status '${session.status}'` });
1122
+ return;
1123
+ }
1124
+
1125
+ const validMethods = [PaymentMethod.UPI, PaymentMethod.COD, PaymentMethod.CASH_COUNTER, PaymentMethod.CARD_COUNTER];
1126
+ if (!validMethods.includes(method)) {
1127
+ res.status(400).json({ error: `Invalid payment method '${method}'. Allowed: ${validMethods.join(', ')}` });
1128
+ return;
1129
+ }
1130
+
1131
+ const merchant = ctx.merchantRepo.findById(session.merchantId);
1132
+ const merchantBranding = merchant?.branding || {};
1133
+ const isCounterRequested = method === PaymentMethod.CASH_COUNTER || method === PaymentMethod.CARD_COUNTER;
1134
+ let sellingMode = merchantBranding.sellingMode || (isCounterRequested ? 'OFFLINE' : 'ONLINE');
1135
+ let rawEnabled = merchantBranding.enabledPaymentMethods || (sellingMode === 'ONLINE' ? ['UPI', 'COD'] : ['UPI', 'CASH_COUNTER', 'CARD_COUNTER']);
1136
+ let enabledMethods = ['UPI'];
1137
+ if (sellingMode === 'ONLINE') {
1138
+ if (rawEnabled.includes('COD')) enabledMethods.push('COD');
1139
+ } else {
1140
+ if (rawEnabled.includes('CASH_COUNTER')) enabledMethods.push('CASH_COUNTER');
1141
+ if (rawEnabled.includes('CARD_COUNTER')) enabledMethods.push('CARD_COUNTER');
1142
+ }
1143
+
1144
+ if (!enabledMethods.includes(method)) {
1145
+ res.status(400).json({ error: `Payment method '${method}' is disabled by the merchant.` });
1146
+ return;
1147
+ }
1148
+
1149
+ const previousMethod = session.method;
1150
+ const isCounter = method === PaymentMethod.CASH_COUNTER || method === PaymentMethod.CARD_COUNTER;
1151
+ const isCod = method === PaymentMethod.COD;
1152
+ const wasCounter = previousMethod === PaymentMethod.CASH_COUNTER || previousMethod === PaymentMethod.CARD_COUNTER;
1153
+ const wasCod = previousMethod === PaymentMethod.COD;
1154
+ const wasUpi = previousMethod === PaymentMethod.UPI || (!wasCounter && !wasCod);
1155
+ const nowIso = new Date().toISOString();
1156
+
1157
+ let updates = `method = ?, updated_at = ?`;
1158
+ let updateParams = [method, nowIso];
1159
+
1160
+ let oldReceivingAccountId = null;
1161
+ let timeRemainingSeconds = 0;
1162
+ let newExpiresAt = null;
1163
+
1164
+ if (isCounter || isCod) {
1165
+ // Switching from UPI to COD or Counter:
1166
+ // Invariant: Concurrency slot and queue window are strictly for UPI payments.
1167
+ // Immediately invalidate and cancel the UPI queue reservation and its active timer.
1168
+ if (session.receivingAccountId) {
1169
+ oldReceivingAccountId = session.receivingAccountId;
1170
+ }
1171
+
1172
+ if (ctx.timingEngine) {
1173
+ ctx.timingEngine.cancelActiveTimer(session.id);
1174
+ }
1175
+
1176
+ if (isCod) {
1177
+ // In Online Selling Mode, if Cash on Delivery is selected:
1178
+ // Run a parallel standalone timer of 5 minutes (300 seconds) which is NOT part of the queue.
1179
+ newExpiresAt = new Date(Date.now() + 5 * 60 * 1000);
1180
+ updates += `, receiving_account_id = NULL, status = 'ACTIVE', queue_position = 0, expires_at = ?`;
1181
+ updateParams.push(newExpiresAt.toISOString());
1182
+ session.receivingAccountId = null;
1183
+ session.status = PaymentStatus.ACTIVE;
1184
+ session.queuePosition = 0;
1185
+ session.expiresAt = newExpiresAt;
1186
+
1187
+ // Start parallel 5-minute timer in timingEngine
1188
+ if (ctx.timingEngine) {
1189
+ ctx.timingEngine.startActiveTimer(session.id, newExpiresAt);
1190
+ }
1191
+ timeRemainingSeconds = 300;
1192
+ } else {
1193
+ // Offline counter payment (CASH_COUNTER or CARD_COUNTER)
1194
+ updates += `, receiving_account_id = NULL, status = 'ACTIVE', queue_position = 0, expires_at = NULL`;
1195
+ session.receivingAccountId = null;
1196
+ session.status = PaymentStatus.ACTIVE;
1197
+ session.queuePosition = 0;
1198
+ session.expiresAt = null;
1199
+ timeRemainingSeconds = 0;
1200
+ }
1201
+ } else if (!isCounter && !isCod && (wasCounter || wasCod)) {
1202
+ // Switching back to UPI:
1203
+ // Cancel any running parallel COD timer
1204
+ if (ctx.timingEngine) {
1205
+ ctx.timingEngine.cancelActiveTimer(session.id);
1206
+ }
1207
+
1208
+ // Re-resolve active receiving account and enqueue/activate into UPI queue
1209
+ const receivingAccount = ctx.accountRepo.findActiveForMerchant(session.merchantId);
1210
+ if (receivingAccount) {
1211
+ const queueResult = ctx.queueEngine.enqueueOrActivate(session.id, receivingAccount.id);
1212
+ updates += `, receiving_account_id = ?, status = ?, queue_position = ?, expires_at = ?`;
1213
+ updateParams.push(
1214
+ receivingAccount.id,
1215
+ queueResult.status,
1216
+ queueResult.queuePosition,
1217
+ queueResult.expiresAt ? queueResult.expiresAt.toISOString() : null
1218
+ );
1219
+ session.receivingAccountId = receivingAccount.id;
1220
+ session.status = queueResult.status;
1221
+ session.queuePosition = queueResult.queuePosition;
1222
+ session.expiresAt = queueResult.expiresAt;
1223
+
1224
+ if (queueResult.expiresAt && ctx.timingEngine && queueResult.status === PaymentStatus.ACTIVE) {
1225
+ ctx.timingEngine.startActiveTimer(session.id, queueResult.expiresAt);
1226
+ }
1227
+ if (queueResult.expiresAt) {
1228
+ timeRemainingSeconds = Math.max(0, Math.ceil((queueResult.expiresAt.getTime() - Date.now()) / 1000));
1229
+ }
1230
+ }
1231
+ } else if (!isCounter && !isCod && session.expiresAt) {
1232
+ timeRemainingSeconds = Math.max(0, Math.ceil((session.expiresAt.getTime() - Date.now()) / 1000));
1233
+ }
1234
+
1235
+ updateParams.push(session.id);
1236
+ ctx.db.execute(`UPDATE payment_sessions SET ${updates} WHERE id = ?`, updateParams);
1237
+ session.method = method;
1238
+
1239
+ // If slot was released on the receiving account, update receiving account and promote waiting queued sessions!
1240
+ if (oldReceivingAccountId) {
1241
+ ctx.db.execute(`
1242
+ UPDATE payment_receiving_accounts
1243
+ SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
1244
+ WHERE id = ? AND (current_active_session_id = ? OR current_active_session_id IS NULL)
1245
+ `, [nowIso, oldReceivingAccountId, session.id]);
1246
+
1247
+ const promoted = ctx.queueEngine.promoteNext(oldReceivingAccountId);
1248
+ if (promoted) {
1249
+ if (promoted.expiresAt && ctx.timingEngine) {
1250
+ ctx.timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
1251
+ }
1252
+ server.broadcastSse(promoted.id, 'status_change', {
1253
+ status: PaymentStatus.ACTIVE,
1254
+ queuePosition: 0,
1255
+ expiresAt: promoted.expiresAt
1256
+ });
1257
+ server.broadcastSse(`queue_${oldReceivingAccountId}`, 'queue_update', {
1258
+ promotedId: promoted.id
1259
+ });
1260
+ } else {
1261
+ ctx.sessionRepo.reindexQueue(oldReceivingAccountId);
1262
+ }
1263
+ }
1264
+
1265
+ // Broadcast SSE update to customer session channel
1266
+ server.broadcastSse(session.id, 'status_change', {
1267
+ status: session.status,
1268
+ method: session.method,
1269
+ queuePosition: session.queuePosition || 0,
1270
+ timeRemainingSeconds,
1271
+ expiresAt: session.expiresAt ? session.expiresAt.toISOString() : null
1272
+ });
1273
+
1274
+ // Broadcast live event to merchant dashboard counter desk if counter method
1275
+ if (isCounter) {
1276
+ server.broadcastSse('admin_counter_events', 'counter_payment_requested', {
1277
+ paymentId: session.id,
1278
+ orderId: session.orderId,
1279
+ amountPaise: session.amountPaise.toString(),
1280
+ method: session.method,
1281
+ status: session.status,
1282
+ createdAt: session.createdAt
1283
+ });
1284
+ }
1285
+
1286
+ if (ctx.auditRepo) {
1287
+ ctx.auditRepo.log({
1288
+ merchantId: session.merchantId,
1289
+ entityType: 'PAYMENT_SESSION',
1290
+ entityId: session.id,
1291
+ action: 'PAYMENT_METHOD_SWITCHED',
1292
+ actorType: 'CUSTOMER',
1293
+ details: { from: previousMethod, to: method }
1294
+ });
1295
+ }
1296
+
1297
+ if (ctx.eventEmitter) {
1298
+ ctx.eventEmitter.emit('PAYMENT.METHOD_SWITCHED', {
1299
+ paymentId: session.id,
1300
+ orderId: session.orderId,
1301
+ merchantId: session.merchantId,
1302
+ fromMethod: previousMethod,
1303
+ toMethod: method
1304
+ });
1305
+ }
1306
+
1307
+ const counterInstr = method === PaymentMethod.CARD_COUNTER
1308
+ ? (merchantBranding.cardCounterInstructions || merchantBranding.counterInstructions || 'Please visit the billing counter and pay via Card POS.')
1309
+ : (merchantBranding.counterInstructions || 'Please visit the billing counter and present this screen to the cashier.');
1310
+
1311
+ res.status(200).json({
1312
+ success: true,
1313
+ paymentId: session.id,
1314
+ method: session.method,
1315
+ status: session.status,
1316
+ timeRemainingSeconds,
1317
+ expiresAt: session.expiresAt ? session.expiresAt.toISOString() : null,
1318
+ counterInstructions: counterInstr
1319
+ });
1320
+ });
1321
+
1322
+ // POST /api/v1/checkout/:id/confirm-cod (Confirm Cash on Delivery Order)
1323
+ router.post('/api/v1/checkout/:id/confirm-cod', async (req, res) => {
1324
+ const session = ctx.sessionRepo.findById(req.params.id);
1325
+ if (!session) {
1326
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1327
+ return;
1328
+ }
1329
+
1330
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
1331
+ if (!deviceAccess.allowed) {
1332
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1333
+ return;
1334
+ }
1335
+
1336
+ // PROCESSING: a UPI payment was already detected and is being verified; switching it to COD would drop that payment.
1337
+ if (session.status === PaymentStatus.PROCESSING) {
1338
+ res.status(400).json({ error: 'Cannot place COD order while payment is in PROCESSING status' });
1339
+ return;
1340
+ }
1341
+
1342
+ if ([PaymentStatus.SUCCESS, PaymentStatus.FAILED, PaymentStatus.EXPIRED, PaymentStatus.CANCELLED].includes(session.status)) {
1343
+ res.status(400).json({ error: `Cannot place COD order for terminal session in status '${session.status}'` });
1344
+ return;
1345
+ }
1346
+
1347
+ // COD terminates the session as SUCCESS; make sure the state machine permits that edge
1348
+ // (INITIATED/QUEUED/ACTIVE are all legal; anything else is caught above).
1349
+ if (!isValidTransition(session.status, PaymentStatus.SUCCESS)) {
1350
+ res.status(409).json({ error: `Cannot place COD order for session in status '${session.status}'.` });
1351
+ return;
1352
+ }
1353
+
1354
+ const merchant = ctx.merchantRepo.findById(session.merchantId);
1355
+ const branding = merchant?.branding || {};
1356
+ const sellingMode = branding.sellingMode || (branding.enabledPaymentMethods?.some(m => m === 'CASH_COUNTER' || m === 'CARD_COUNTER') ? 'OFFLINE' : 'ONLINE');
1357
+ if (sellingMode !== 'ONLINE') {
1358
+ res.status(400).json({ error: 'Cash on Delivery is only available in Online Selling Mode.' });
1359
+ return;
1360
+ }
1361
+
1362
+ const rawEnabled = branding.enabledPaymentMethods || ['UPI'];
1363
+ if (!rawEnabled.includes('COD')) {
1364
+ res.status(400).json({ error: 'Cash on Delivery is currently disabled by merchant.' });
1365
+ return;
1366
+ }
1367
+
1368
+ // Cancel Active Timer
1369
+ if (ctx.timingEngine) {
1370
+ if (typeof ctx.timingEngine.cancelActiveTimer === 'function') {
1371
+ ctx.timingEngine.cancelActiveTimer(session.id);
1372
+ }
1373
+ }
1374
+
1375
+ // Update payment session to SUCCESS with COD method and release slot inside transaction
1376
+ const nowIso = new Date().toISOString();
1377
+ const oldReceivingAccountId = session.receivingAccountId || session.receiving_account_id;
1378
+
1379
+ ctx.sessionRepo.db.transaction(() => {
1380
+ ctx.sessionRepo.db.execute(`
1381
+ UPDATE payment_sessions
1382
+ SET method = 'COD',
1383
+ status = 'SUCCESS',
1384
+ verification_tag = 'COD_ORDER_PLACED',
1385
+ verification_level = 1,
1386
+ completed_at = ?,
1387
+ receiving_account_id = NULL,
1388
+ updated_at = ?
1389
+ WHERE id = ? AND status IN ('INITIATED', 'QUEUED', 'ACTIVE')
1390
+ `, [nowIso, nowIso, session.id]);
1391
+
1392
+ // Release receiving account slot
1393
+ if (oldReceivingAccountId) {
1394
+ ctx.sessionRepo.db.execute(`
1395
+ UPDATE payment_receiving_accounts
1396
+ SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
1397
+ WHERE id = ? AND (current_active_session_id = ? OR current_active_session_id IS NULL)
1398
+ `, [nowIso, oldReceivingAccountId, session.id]);
1399
+ }
1400
+ });
1401
+
1402
+ if (oldReceivingAccountId && ctx.queueEngine) {
1403
+ const promoted = ctx.queueEngine.promoteNext(oldReceivingAccountId);
1404
+ if (promoted) {
1405
+ if (promoted.expiresAt && ctx.timingEngine) {
1406
+ ctx.timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
1407
+ }
1408
+ server.broadcastSse(promoted.id, 'status_change', {
1409
+ status: PaymentStatus.ACTIVE,
1410
+ queuePosition: 0,
1411
+ expiresAt: promoted.expiresAt
1412
+ });
1413
+ server.broadcastSse(`queue_${oldReceivingAccountId}`, 'queue_update', {
1414
+ promotedId: promoted.id
1415
+ });
1416
+ }
1417
+ }
1418
+
1419
+ const updated = ctx.sessionRepo.findById(session.id);
1420
+
1421
+ let customer = null;
1422
+ if (updated.customerId && ctx.customerRepo) {
1423
+ const c = ctx.customerRepo.findById(updated.customerId);
1424
+ if (c) {
1425
+ customer = {
1426
+ id: c.id,
1427
+ name: c.name,
1428
+ email: c.email,
1429
+ mobileNumber: c.mobileNumber
1430
+ };
1431
+ }
1432
+ }
1433
+
1434
+ if (ctx.auditRepo) {
1435
+ ctx.auditRepo.log({
1436
+ merchantId: updated.merchantId,
1437
+ entityType: 'PAYMENT_SESSION',
1438
+ entityId: updated.id,
1439
+ action: 'COD_ORDER_PLACED',
1440
+ actorType: 'CUSTOMER',
1441
+ details: { orderId: updated.orderId, amountPaise: updated.amountPaise.toString() }
1442
+ });
1443
+ }
1444
+
1445
+ // Broadcast SSE
1446
+ server.broadcastSse(req.params.id, 'status_change', {
1447
+ status: 'SUCCESS',
1448
+ method: 'COD',
1449
+ verificationTag: 'COD_ORDER_PLACED'
1450
+ });
1451
+
1452
+ // Webhook dispatch
1453
+ if (ctx.webhookEngine && merchant && merchant.webhookUrl) {
1454
+ ctx.webhookEngine.dispatch('SUCCESS', updated).catch(err => console.error('COD Webhook dispatch failed:', err.message));
1455
+ }
1456
+
1457
+ res.status(200).json({
1458
+ success: true,
1459
+ paymentId: updated.id,
1460
+ orderId: updated.orderId,
1461
+ amount: toRupeesString(updated.amountPaise),
1462
+ amountPaise: updated.amountPaise.toString(),
1463
+ currency: updated.currency,
1464
+ method: 'COD',
1465
+ status: 'SUCCESS',
1466
+ verificationTag: 'COD_ORDER_PLACED',
1467
+ customer,
1468
+ returnUrl: session.returnUrl
1469
+ });
1470
+ });
1471
+
1472
+ // GET /api/v1/checkout/:id/cod (Query COD order info)
1473
+ router.get('/api/v1/checkout/:id/cod', async (req, res) => {
1474
+ const session = ctx.sessionRepo.findById(req.params.id);
1475
+ if (!session) {
1476
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1477
+ return;
1478
+ }
1479
+ let customer = null;
1480
+ if (isPaymentOwner(req, session, ctx) && session.customerId && ctx.customerRepo) {
1481
+ const c = ctx.customerRepo.findById(session.customerId);
1482
+ if (c) {
1483
+ customer = {
1484
+ id: c.id,
1485
+ name: c.name,
1486
+ email: c.email,
1487
+ mobileNumber: c.mobileNumber
1488
+ };
1489
+ }
1490
+ }
1491
+ res.status(200).json({
1492
+ paymentId: session.id,
1493
+ orderId: session.orderId,
1494
+ amount: toRupeesString(session.amountPaise),
1495
+ amountPaise: session.amountPaise.toString(),
1496
+ currency: session.currency,
1497
+ method: session.method,
1498
+ status: session.status,
1499
+ verificationTag: session.verificationTag,
1500
+ customer,
1501
+ placedAt: session.completedAt || session.createdAt
1502
+ });
1503
+ });
1504
+
1505
+ // POST /api/v1/checkout/:id/expire (Explicit Client Expiry Trigger)
1506
+ router.post('/api/v1/checkout/:id/expire', async (req, res) => {
1507
+ const session = ctx.sessionRepo.findById(req.params.id);
1508
+ if (!session) {
1509
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1510
+ return;
1511
+ }
1512
+
1513
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
1514
+ if (!deviceAccess.allowed) {
1515
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1516
+ return;
1517
+ }
1518
+
1519
+ if (session.status === PaymentStatus.ACTIVE) {
1520
+ ctx.timingEngine.handleActiveTimerExpiration(session.id);
1521
+ server.broadcastSse(session.id, 'status_change', { status: PaymentStatus.EXPIRED });
1522
+ }
1523
+
1524
+ res.status(200).json({
1525
+ success: true,
1526
+ paymentId: session.id,
1527
+ status: PaymentStatus.EXPIRED
1528
+ });
1529
+ });
1530
+
1531
+ // POST /api/v1/checkout/:id/trigger-hook (Merchant Action & Telemetry Trigger)
1532
+ router.post('/api/v1/checkout/:id/trigger-hook', async (req, res) => {
1533
+ const body = req.body || {};
1534
+ const { action, details } = body;
1535
+
1536
+ const session = ctx.sessionRepo.findById(req.params.id);
1537
+ if (!session) {
1538
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1539
+ return;
1540
+ }
1541
+
1542
+ if (ctx.auditRepo) {
1543
+ ctx.auditRepo.log({
1544
+ merchantId: session.merchantId,
1545
+ entityType: 'PAYMENT_SESSION',
1546
+ entityId: session.id,
1547
+ action: `HOOK_${action || 'USER_ACTION'}`,
1548
+ actorType: 'CUSTOMER',
1549
+ details: details || {}
1550
+ });
1551
+ }
1552
+
1553
+ if (ctx.eventEmitter) {
1554
+ ctx.eventEmitter.emit('PAYMENT.ACTION_TRIGGERED', {
1555
+ paymentId: session.id,
1556
+ orderId: session.orderId,
1557
+ merchantId: session.merchantId,
1558
+ action: action || 'ACTION',
1559
+ details: details || {}
1560
+ });
1561
+ }
1562
+
1563
+ res.status(200).json({
1564
+ success: true,
1565
+ action: action || 'ACTION',
1566
+ timestamp: new Date().toISOString()
1567
+ });
1568
+ });
1569
+
1570
+ // POST /api/v1/checkout/:id/simulate-observation (Inject simulated SMS / Email observation)
1571
+ router.post('/api/v1/checkout/:id/simulate-observation', async (req, res) => {
1572
+ const session = ctx.sessionRepo.findById(req.params.id);
1573
+ if (!session) {
1574
+ res.status(404).json({ error: 'Session not found' });
1575
+ return;
1576
+ }
1577
+
1578
+ const body = req.body || {};
1579
+ const source = (body.source || 'SMS').toUpperCase();
1580
+ const rawText = (body.rawText || body.message || body.text || '').toString();
1581
+ const sender = (body.sender || '').toString();
1582
+
1583
+ if (!rawText) {
1584
+ res.status(400).json({ error: 'Missing message body' });
1585
+ return;
1586
+ }
1587
+
1588
+ const pipeline = ctx.pipeline;
1589
+ if (!pipeline) {
1590
+ res.status(500).json({ error: 'Verification pipeline not available' });
1591
+ return;
1592
+ }
1593
+
1594
+ const result = pipeline.process({
1595
+ source,
1596
+ rawText,
1597
+ sender,
1598
+ paymentSessionId: session.id,
1599
+ receivingAccountId: session.receivingAccountId,
1600
+ isTest: Boolean(body.isTest),
1601
+ testModeActive: Boolean(body.testModeActive)
1602
+ });
1603
+
1604
+ if (result.status === 'REJECTED' && result.failingCondition === 'FRAUDULENT_PAYMENT_INJECTION') {
1605
+ res.status(403).json({
1606
+ status: 'BLOCKED',
1607
+ isBanned: true,
1608
+ fraudDetected: true,
1609
+ reason: 'FRAUDULENT_PAYMENT_INJECTION',
1610
+ error: 'FRAUDULENT_PAYMENT_INJECTION: Fake payment observation detected. User banned and session terminated.'
1611
+ });
1612
+ return;
1613
+ }
1614
+
1615
+ res.status(200).json({
1616
+ success: result.success,
1617
+ status: result.status,
1618
+ result
1619
+ });
1620
+ });
1621
+
1622
+ // POST /api/v1/checkout/:id/end-and-restart-payment (or end-session-and-create-new)
1623
+ // Cancels previous session created on another device and creates a fresh payment session bound to current device
1624
+ async function handleEndAndRestartPayment(req, res, params) {
1625
+ const session = ctx.sessionRepo.findById(req.params.id);
1626
+ const body = req.body || {};
1627
+ const conflictingSessionId = body?.conflictingSessionId || body?.conflicting_session_id || null;
1628
+
1629
+ if (!session && !conflictingSessionId) {
1630
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1631
+ return;
1632
+ }
1633
+
1634
+ const targetSession = session || ctx.sessionRepo.findById(conflictingSessionId);
1635
+ if (!targetSession) {
1636
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
1637
+ return;
1638
+ }
1639
+
1640
+ const newDeviceId = req.headers['x-device-id'] || body?.deviceId || null;
1641
+ if (!newDeviceId) {
1642
+ res.status(400).json({ error: 'Current device identity is required to bind the new payment session.' });
1643
+ return;
1644
+ }
1645
+
1646
+ const sourceSession = (conflictingSessionId ? ctx.sessionRepo.findById(conflictingSessionId) : null) || targetSession;
1647
+
1648
+ // A settled order must not be restarted. The restarted session deliberately reuses the
1649
+ // merchant's order reference, and the unique (merchant_id, order_id) index rejects a
1650
+ // second row for an order that already succeeded — which surfaced as a 500. Refuse with a
1651
+ // clear conflict instead of attempting the duplicate.
1652
+ const paidSession = [sourceSession, targetSession, session].find(s => s && s.status === PaymentStatus.SUCCESS);
1653
+ if (paidSession) {
1654
+ res.status(409).json({
1655
+ error: 'ORDER_ALREADY_PAID',
1656
+ message: 'This order has already been paid and cannot be restarted.',
1657
+ orderId: paidSession.orderId,
1658
+ status: PaymentStatus.SUCCESS
1659
+ });
1660
+ return;
1661
+ }
1662
+
1663
+ const processingSession = [sourceSession, targetSession, session].find(s => s && s.status === PaymentStatus.PROCESSING);
1664
+ if (processingSession) {
1665
+ res.status(409).json({
1666
+ error: 'SESSION_PROCESSING',
1667
+ message: 'Cannot terminate or restart a payment session while bank verification is in progress (PROCESSING).',
1668
+ sessionId: processingSession.id,
1669
+ status: PaymentStatus.PROCESSING
1670
+ });
1671
+ return;
1672
+ }
1673
+
1674
+ const newDeviceToken = req.headers['x-device-token'] || body?.deviceToken || null;
1675
+ const newDeviceSignature = req.headers['x-device-signature'] || body?.deviceSignature || null;
1676
+ const newSignaturePayload = req.headers['x-device-payload'] || body?.signaturePayload || null;
1677
+
1678
+ const credCheck = verifyRegisteredDeviceCredentials({
1679
+ deviceId: newDeviceId,
1680
+ deviceToken: newDeviceToken,
1681
+ deviceSignature: newDeviceSignature,
1682
+ signaturePayload: newSignaturePayload,
1683
+ deviceRepo: ctx.deviceRepo
1684
+ });
1685
+ if (!credCheck.valid) {
1686
+ res.status(401).json({
1687
+ error: credCheck.error || 'DEVICE_CREDENTIALS_INVALID',
1688
+ message: credCheck.message || 'A registered device and valid device credential are required.'
1689
+ });
1690
+ return;
1691
+ }
1692
+
1693
+ // Never accept an unverified customerId from the request body!
1694
+ const rawCustToken = body?.token ||
1695
+ body?.sessionToken ||
1696
+ req.headers['x-customer-token'] ||
1697
+ ((req.headers['authorization'] || '').startsWith('Bearer ') ? req.headers['authorization'].slice(7).trim() : null);
1698
+ const authCtx = (rawCustToken && ctx.customerSessionRepo)
1699
+ ? ctx.customerSessionRepo.findByToken(String(rawCustToken).trim())
1700
+ : null;
1701
+ const authenticatedCustomerId = authCtx?.customer?.id || null;
1702
+
1703
+ if (body?.customerId && (!authenticatedCustomerId || body.customerId !== authenticatedCustomerId)) {
1704
+ res.status(401).json({
1705
+ error: 'CUSTOMER_AUTH_REQUIRED',
1706
+ message: 'Customer identity cannot be supplied in the request body without a matching valid customer session token.'
1707
+ });
1708
+ return;
1709
+ }
1710
+
1711
+ if (authenticatedCustomerId && sourceSession?.customerId && sourceSession.customerId !== authenticatedCustomerId) {
1712
+ res.status(403).json({
1713
+ error: 'CUSTOMER_MISMATCH',
1714
+ message: 'Authenticated customer does not match the payment session owner.'
1715
+ });
1716
+ return;
1717
+ }
1718
+
1719
+ const customerId = authenticatedCustomerId || sourceSession?.customerId || targetSession.customerId || null;
1720
+
1721
+ // 1. Identify ALL sessions on old/other devices that must be cancelled (excluding PROCESSING and terminal states)
1722
+ const NON_CANCELLABLE_STATES = [
1723
+ PaymentStatus.PROCESSING,
1724
+ PaymentStatus.CANCELLED,
1725
+ PaymentStatus.EXPIRED,
1726
+ PaymentStatus.FAILED,
1727
+ PaymentStatus.SUCCESS,
1728
+ PaymentStatus.SUSPICIOUS
1729
+ ];
1730
+ const sessionsToTerminate = new Map();
1731
+
1732
+ if (!NON_CANCELLABLE_STATES.includes(targetSession.status)) {
1733
+ sessionsToTerminate.set(targetSession.id, targetSession);
1734
+ }
1735
+
1736
+ if (conflictingSessionId) {
1737
+ const confSess = ctx.sessionRepo.findById(conflictingSessionId);
1738
+ if (confSess?.status === PaymentStatus.PROCESSING) {
1739
+ res.status(409).json({
1740
+ error: 'SESSION_PROCESSING',
1741
+ message: 'Conflicting payment session is currently in bank verification (PROCESSING) and cannot be terminated.'
1742
+ });
1743
+ return;
1744
+ }
1745
+ if (confSess && !NON_CANCELLABLE_STATES.includes(confSess.status)) {
1746
+ sessionsToTerminate.set(confSess.id, confSess);
1747
+ }
1748
+ }
1749
+
1750
+ if (customerId && ctx.sessionRepo.findAllActiveOrPendingForCustomer) {
1751
+ const custSessions = ctx.sessionRepo.findAllActiveOrPendingForCustomer(customerId);
1752
+ for (const cs of custSessions) {
1753
+ if (cs.status === PaymentStatus.PROCESSING) {
1754
+ res.status(409).json({
1755
+ error: 'SESSION_PROCESSING',
1756
+ message: 'Another payment session for this customer is currently in bank verification (PROCESSING) and cannot be terminated.'
1757
+ });
1758
+ return;
1759
+ }
1760
+ if (!NON_CANCELLABLE_STATES.includes(cs.status)) {
1761
+ if (!cs.deviceId || cs.deviceId !== newDeviceId || cs.id === targetSession.id) {
1762
+ sessionsToTerminate.set(cs.id, cs);
1763
+ }
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ if (session && session.id !== targetSession.id && !NON_CANCELLABLE_STATES.includes(session.status)) {
1769
+ sessionsToTerminate.set(session.id, session);
1770
+ }
1771
+
1772
+ // Also check if an active session on the receiving account belongs to the same order / customer
1773
+ const receivingAccountIdForLookup = targetSession.receivingAccountId || session?.receivingAccountId;
1774
+ if (receivingAccountIdForLookup && ctx.sessionRepo.findCurrentActiveOrProcessing) {
1775
+ const activeOnAcc = ctx.sessionRepo.findCurrentActiveOrProcessing(receivingAccountIdForLookup);
1776
+ if (activeOnAcc && activeOnAcc.deviceId !== newDeviceId) {
1777
+ const cleanTargetOrder = targetSession.orderId ? targetSession.orderId.replace(/-R\d+$/, '') : '';
1778
+ const cleanActiveOrder = activeOnAcc.orderId ? activeOnAcc.orderId.replace(/-R\d+$/, '') : '';
1779
+ if ((cleanTargetOrder && cleanTargetOrder === cleanActiveOrder) || (customerId && activeOnAcc.customerId === customerId)) {
1780
+ if (activeOnAcc.status === PaymentStatus.PROCESSING) {
1781
+ res.status(409).json({
1782
+ error: 'SESSION_PROCESSING',
1783
+ message: 'Active payment session for this order is currently in bank verification (PROCESSING) and cannot be terminated.'
1784
+ });
1785
+ return;
1786
+ }
1787
+ sessionsToTerminate.set(activeOnAcc.id, activeOnAcc);
1788
+ }
1789
+ }
1790
+ }
1791
+
1792
+ // Resolve receiving account ID for new session (guarantee active account for merchant)
1793
+ const activeMerchantAcc = ctx.accountRepo ? ctx.accountRepo.findActiveForMerchant(sourceSession.merchantId) : null;
1794
+ const targetReceivingAccountId = activeMerchantAcc?.id || sourceSession.receivingAccountId;
1795
+
1796
+ const affectedAccountIds = new Set();
1797
+ for (const [sId, sObj] of sessionsToTerminate.entries()) {
1798
+ if (sObj?.receivingAccountId) {
1799
+ affectedAccountIds.add(sObj.receivingAccountId);
1800
+ }
1801
+ }
1802
+ if (receivingAccountIdForLookup) {
1803
+ affectedAccountIds.add(receivingAccountIdForLookup);
1804
+ }
1805
+ if (targetReceivingAccountId) {
1806
+ affectedAccountIds.add(targetReceivingAccountId);
1807
+ }
1808
+
1809
+ // 2. Terminate all old sessions, cancel timers, and broadcast SSE
1810
+ for (const [sId, sObj] of sessionsToTerminate.entries()) {
1811
+ ctx.sessionRepo.transitionToTerminal(sId, PaymentStatus.CANCELLED, {
1812
+ reason: 'REPLACED_ON_ANOTHER_DEVICE',
1813
+ replacedByDeviceId: newDeviceId
1814
+ });
1815
+
1816
+ if (ctx.timingEngine) {
1817
+ ctx.timingEngine.cancelActiveTimer(sId);
1818
+ if (typeof ctx.timingEngine.clearTimers === 'function') {
1819
+ ctx.timingEngine.clearTimers(sId);
1820
+ }
1821
+ }
1822
+
1823
+ server.broadcastSse(sId, 'status_change', {
1824
+ status: PaymentStatus.CANCELLED,
1825
+ reason: 'REPLACED_ON_ANOTHER_DEVICE',
1826
+ message: 'Payment session closed because you continued on another device.'
1827
+ });
1828
+ }
1829
+
1830
+ // 3. Immediately release receiving-account slot, re-index queue, and promote next queued session if applicable
1831
+ const termIds = Array.from(sessionsToTerminate.keys());
1832
+ for (const accId of affectedAccountIds) {
1833
+ const nowIso = new Date().toISOString();
1834
+ if (termIds.length > 0) {
1835
+ ctx.db.execute(`
1836
+ UPDATE payment_receiving_accounts
1837
+ SET is_slot_busy = 0, current_active_session_id = NULL, updated_at = ?
1838
+ WHERE id = ? AND (current_active_session_id IN (${termIds.map(() => '?').join(',')}) OR current_active_session_id IS NULL)
1839
+ `, [nowIso, accId, ...termIds]);
1840
+ }
1841
+
1842
+ if (ctx.sessionRepo.reindexQueue) {
1843
+ ctx.sessionRepo.reindexQueue(accId);
1844
+ }
1845
+
1846
+ if (ctx.queueEngine && typeof ctx.queueEngine.promoteNext === 'function') {
1847
+ const promoted = ctx.queueEngine.promoteNext(accId);
1848
+ if (promoted) {
1849
+ if (ctx.timingEngine && promoted.expiresAt) {
1850
+ ctx.timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
1851
+ }
1852
+ server.broadcastSse(promoted.id, 'status_change', {
1853
+ status: PaymentStatus.ACTIVE,
1854
+ queuePosition: 0,
1855
+ expiresAt: promoted.expiresAt
1856
+ });
1857
+ server.broadcastSse('admin_counter_events', 'status_change', {
1858
+ status: PaymentStatus.ACTIVE,
1859
+ sessionId: promoted.id,
1860
+ orderId: promoted.orderId,
1861
+ amountPaise: promoted.amountPaise ? promoted.amountPaise.toString() : '0'
1862
+ });
1863
+ }
1864
+ }
1865
+ }
1866
+
1867
+ // 4. Preserve merchant's exact order reference from sourceSession
1868
+ const newOrderId = sourceSession.orderId ? sourceSession.orderId.replace(/-R\d+$/, '') : `ORD-${Date.now()}`;
1869
+
1870
+ // 5. Touch verified device
1871
+ if (newDeviceId && ctx.deviceRepo) {
1872
+ ctx.deviceRepo.touch(newDeviceId);
1873
+ }
1874
+
1875
+ // 6. Create fresh payment session bound to newDeviceId
1876
+ const newSession = ctx.sessionRepo.create({
1877
+ merchantId: sourceSession.merchantId,
1878
+ customerId: customerId || sourceSession.customerId,
1879
+ receivingAccountId: targetReceivingAccountId,
1880
+ orderId: newOrderId,
1881
+ amountPaise: sourceSession.amountPaise,
1882
+ currency: sourceSession.currency,
1883
+ method: sourceSession.method || PaymentMethod.UPI,
1884
+ status: PaymentStatus.INITIATED,
1885
+ returnUrl: sourceSession.returnUrl,
1886
+ deviceId: newDeviceId,
1887
+ metadata: {
1888
+ ...(sourceSession.metadata || {}),
1889
+ reboundFromSessionId: sourceSession.id,
1890
+ reboundAt: new Date().toISOString()
1891
+ }
1892
+ });
1893
+
1894
+ if (customerId && ctx.deviceRepo) {
1895
+ try { ctx.deviceRepo.associateCustomer(newDeviceId, customerId); } catch {}
1896
+ }
1897
+
1898
+ // 7. Enqueue or activate new session if UPI
1899
+ let queueResult = { status: newSession.status, queuePosition: 0, expiresAt: null };
1900
+ if (newSession.receivingAccountId && (String(newSession.method).toUpperCase() === 'UPI' || !newSession.method)) {
1901
+ queueResult = ctx.queueEngine.enqueueOrActivate(newSession.id, newSession.receivingAccountId);
1902
+ if (queueResult.expiresAt && ctx.timingEngine) {
1903
+ ctx.timingEngine.startActiveTimer(newSession.id, queueResult.expiresAt);
1904
+ }
1905
+ server.broadcastSse(newSession.id, 'status_change', {
1906
+ status: queueResult.status,
1907
+ queuePosition: queueResult.queuePosition,
1908
+ expiresAt: queueResult.expiresAt
1909
+ });
1910
+ server.broadcastSse('admin_counter_events', 'status_change', {
1911
+ status: queueResult.status,
1912
+ sessionId: newSession.id,
1913
+ orderId: newSession.orderId,
1914
+ amountPaise: newSession.amountPaise ? newSession.amountPaise.toString() : '0'
1915
+ });
1916
+ }
1917
+
1918
+ if (ctx.auditRepo) {
1919
+ ctx.auditRepo.log({
1920
+ merchantId: sourceSession.merchantId,
1921
+ entityType: 'PAYMENT_SESSION',
1922
+ entityId: newSession.id,
1923
+ action: 'PAYMENT_SESSION_RECREATED_ON_NEW_DEVICE',
1924
+ actorType: 'CUSTOMER',
1925
+ actorId: customerId || 'ANONYMOUS',
1926
+ details: {
1927
+ terminatedSessionIds: Array.from(sessionsToTerminate.keys()),
1928
+ newSessionId: newSession.id,
1929
+ newDeviceId
1930
+ }
1931
+ });
1932
+ }
1933
+
1934
+ res.status(201).json({
1935
+ success: true,
1936
+ oldSessionId: targetSession.id,
1937
+ terminatedSessionIds: Array.from(sessionsToTerminate.keys()),
1938
+ newSessionId: newSession.id,
1939
+ paymentId: newSession.id,
1940
+ orderId: newSession.orderId,
1941
+ checkoutUrl: `/checkout/${newSession.id}`,
1942
+ status: queueResult.status,
1943
+ queuePosition: queueResult.queuePosition,
1944
+ expiresAt: queueResult.expiresAt,
1945
+ message: 'Previous sessions terminated. New payment session activated and bound to this device.'
1946
+ });
1947
+ }
1948
+
1949
+ router.post('/api/v1/checkout/:id/end-and-restart-payment', handleEndAndRestartPayment);
1950
+ router.post('/api/v1/checkout/:id/end-session-and-create-new', handleEndAndRestartPayment);
1951
+
1952
+ // GET /api/v1/checkout/:id/stream and /events (SSE)
1953
+ router.get('/api/v1/checkout/:id/stream', async (req, res) => {
1954
+ const session = ctx.sessionRepo.findById(req.params.id);
1955
+ if (!session) {
1956
+ res.status(404).json({ error: 'Session not found' });
1957
+ return;
1958
+ }
1959
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
1960
+ if (!deviceAccess.allowed) {
1961
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1962
+ return;
1963
+ }
1964
+ res.writeHead(200, {
1965
+ 'Content-Type': 'text/event-stream',
1966
+ 'Cache-Control': 'no-cache',
1967
+ 'Connection': 'keep-alive',
1968
+ 'Access-Control-Allow-Origin': '*'
1969
+ });
1970
+ res.write('event: connected\ndata: {"status":"connected"}\n\n');
1971
+ const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1972
+ req.on('close', () => clearInterval(interval));
1973
+ });
1974
+
1975
+ router.get('/api/v1/checkout/:id/events', async (req, res) => {
1976
+ const session = ctx.sessionRepo.findById(req.params.id);
1977
+ if (!session) {
1978
+ res.status(404).json({ error: 'Session not found' });
1979
+ return;
1980
+ }
1981
+ const deviceAccess = checkSessionDeviceAccess(session, req, null, ctx);
1982
+ if (!deviceAccess.allowed) {
1983
+ res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1984
+ return;
1985
+ }
1986
+ res.writeHead(200, {
1987
+ 'Content-Type': 'text/event-stream',
1988
+ 'Cache-Control': 'no-cache',
1989
+ 'Connection': 'keep-alive',
1990
+ 'Access-Control-Allow-Origin': '*'
1991
+ });
1992
+ res.write('event: connected\ndata: {"status":"connected"}\n\n');
1993
+ const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1994
+ req.on('close', () => clearInterval(interval));
1995
+ });
1996
+ }
1997
+
1998
+
1999
+
2000
+