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.
- package/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2424 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- 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
|
+
|