elpaypg 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2421 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,446 @@
1
+ import { Router } from 'express';
2
+ import { toPaise, toRupeesString, resolveRuntimeUrls, PaymentStatus, safeEqual } from '../vendor/common/index.js';
3
+ import { isTerminalStatus, toPublicOutcome, verifyRegisteredDeviceCredentials } from '../vendor/core/index.js';
4
+ import { isPaymentOwner } from '../auth-guard.js';
5
+
6
+ /**
7
+ * Registers merchant-facing payment routes.
8
+ * @param {HttpServer} server
9
+ * @param {{
10
+ * merchantRepo: import('../vendor/db/index.js').MerchantRepository,
11
+ * accountRepo: import('../vendor/db/index.js').ReceivingAccountRepository,
12
+ * sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
13
+ * queueEngine: import('../vendor/core/index.js').QueueEngine,
14
+ * timingEngine: import('../vendor/core/index.js').TimingEngine,
15
+ * auditRepo: import('../vendor/db/index.js').AuditLogRepository,
16
+ * checkoutBaseUrl: string
17
+ * }} ctx
18
+ */
19
+ export function registerPaymentRoutes(router, ctx) {
20
+ function resolveCheckoutBaseUrl(merchant) {
21
+ return ctx.checkoutBaseUrl || 'http://localhost:5000';
22
+ }
23
+
24
+ // POST /api/v1/payments
25
+ router.post('/api/v1/payments', async (req, res) => {
26
+ const body = req.body || {};
27
+ const idempotencyKey = req.headers['idempotency-key'] || body.idempotencyKey;
28
+
29
+ // Resolve merchant (default to first/demo merchant if none passed in header)
30
+ const merchantId = req.headers['x-merchant-id'] || body.merchantId;
31
+ let merchant;
32
+ if (merchantId) {
33
+ merchant = ctx.merchantRepo.findById(merchantId);
34
+ if (!merchant) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : null;
35
+ } else {
36
+ merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
37
+ }
38
+
39
+ if (!merchant) {
40
+ res.status(400).json({ error: 'Merchant not found or not configured.' });
41
+ return;
42
+ }
43
+
44
+ if (req.headers['x-merchant-key']) {
45
+ if (!merchant.webhookSecret || !safeEqual(req.headers['x-merchant-key'].trim(), merchant.webhookSecret)) {
46
+ res.status(401).json({ error: 'Invalid merchant key', code: 'INVALID_MERCHANT_KEY' });
47
+ return;
48
+ }
49
+ }
50
+
51
+ if (!body.orderId || !body.amount) {
52
+ res.status(400).json({ error: 'Missing required fields: orderId and amount.' });
53
+ return;
54
+ }
55
+
56
+ // returnUrl is echoed back to the customer's browser as a redirect target after
57
+ // payment, so only absolute http(s) URLs are ever stored. This is the write-side
58
+ // complement to the checkout page's sanitizeRedirectUrl.
59
+ if (body.returnUrl !== undefined && body.returnUrl !== null && body.returnUrl !== '') {
60
+ if (typeof body.returnUrl !== 'string') {
61
+ res.status(400).json({ error: 'returnUrl must be a string.' });
62
+ return;
63
+ }
64
+ let parsedReturnUrl;
65
+ try {
66
+ parsedReturnUrl = new URL(body.returnUrl.trim());
67
+ } catch {
68
+ res.status(400).json({ error: 'returnUrl must be an absolute http(s) URL.' });
69
+ return;
70
+ }
71
+ if (parsedReturnUrl.protocol !== 'http:' && parsedReturnUrl.protocol !== 'https:') {
72
+ res.status(400).json({ error: 'returnUrl must use the http or https scheme.' });
73
+ return;
74
+ }
75
+ body.returnUrl = parsedReturnUrl.href;
76
+ }
77
+
78
+ const deviceId = body.deviceId || req.headers['x-device-id'] || null;
79
+ const deviceToken = body.deviceToken || req.headers['x-device-token'] || null;
80
+ const deviceSignature = body.deviceSignature || req.headers['x-device-signature'] || null;
81
+ const signaturePayload = body.signaturePayload || req.headers['x-device-payload'] || null;
82
+
83
+ if (deviceId) {
84
+ const credCheck = verifyRegisteredDeviceCredentials({
85
+ deviceId,
86
+ deviceToken,
87
+ deviceSignature,
88
+ signaturePayload,
89
+ deviceRepo: ctx.deviceRepo
90
+ });
91
+ if (!credCheck.valid) {
92
+ res.status(401).json({
93
+ error: credCheck.error || 'DEVICE_CREDENTIALS_INVALID',
94
+ message: credCheck.message || 'A registered device and valid device credential are required.'
95
+ });
96
+ return;
97
+ }
98
+ }
99
+
100
+ // Check idempotency & active session state
101
+ const existing = ctx.sessionRepo.findByOrderId(merchant.id, body.orderId);
102
+ if (existing) {
103
+ // If SUCCESS: order is already paid, return existing completed session
104
+ if (existing.status === PaymentStatus.SUCCESS) {
105
+ res.status(200).json({
106
+ paymentId: existing.id,
107
+ orderId: existing.orderId,
108
+ amount: toRupeesString(existing.amountPaise),
109
+ amountPaise: existing.amountPaise.toString(),
110
+ currency: existing.currency,
111
+ status: existing.status,
112
+ checkoutUrl: `${resolveCheckoutBaseUrl(merchant)}/checkout/${existing.id}`
113
+ });
114
+ return;
115
+ }
116
+
117
+ // Check if existing session is stale
118
+ const now = Date.now();
119
+ const isStale = (existing.status === PaymentStatus.ACTIVE && existing.expiresAt && existing.expiresAt.getTime() <= now) ||
120
+ (existing.status === PaymentStatus.PROCESSING && existing.processingExpiresAt && existing.processingExpiresAt.getTime() <= now);
121
+ if (isStale) {
122
+ const terminalStatus = existing.status === PaymentStatus.PROCESSING ? PaymentStatus.FAILED : PaymentStatus.EXPIRED;
123
+ ctx.sessionRepo.transitionToTerminal(existing.id, terminalStatus);
124
+ if (existing.receivingAccountId) {
125
+ ctx.queueEngine.promoteNext(existing.receivingAccountId);
126
+ }
127
+ existing.status = terminalStatus;
128
+ if (ctx.eventEmitter) {
129
+ ctx.eventEmitter.emit(`PAYMENT.${terminalStatus}`, {
130
+ paymentId: existing.id,
131
+ orderId: existing.orderId,
132
+ merchantId: existing.merchantId,
133
+ amountPaise: existing.amountPaise
134
+ });
135
+ }
136
+ }
137
+
138
+ // If existing is non-terminal (ACTIVE, PROCESSING, QUEUED, INITIATED):
139
+ if ([PaymentStatus.ACTIVE, PaymentStatus.PROCESSING, PaymentStatus.QUEUED, PaymentStatus.INITIATED].includes(existing.status)) {
140
+ // CASE 3: Active session bound to a device + missing or different device -> DUPLICATE_SESSION_DETECTED
141
+ if (existing.deviceId && (!deviceId || deviceId !== existing.deviceId)) {
142
+ res.status(409).json({
143
+ error: 'DUPLICATE_SESSION_DETECTED',
144
+ code: 'DEVICE_SESSION_MISMATCH',
145
+ session_id: existing.id,
146
+ activeSession: {
147
+ paymentId: existing.id,
148
+ orderId: existing.orderId,
149
+ amount: toRupeesString(existing.amountPaise)
150
+ },
151
+ action: 'END_SESSION_AND_CREATE_NEW',
152
+ message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.'
153
+ });
154
+ return;
155
+ }
156
+
157
+ // CASE 2: Active session + Same device -> CONTINUE ACTIVE SESSION
158
+ res.status(200).json({
159
+ paymentId: existing.id,
160
+ orderId: existing.orderId,
161
+ amount: toRupeesString(existing.amountPaise),
162
+ amountPaise: existing.amountPaise.toString(),
163
+ currency: existing.currency,
164
+ status: existing.status,
165
+ activeSessionDetected: true,
166
+ checkoutUrl: `${resolveCheckoutBaseUrl(merchant)}/checkout/${existing.id}`
167
+ });
168
+ return;
169
+ }
170
+
171
+ // CASE 4: Previous session is TERMINAL (FAILED, EXPIRED, CANCELLED) -> RETRY / NEW ATTEMPT!
172
+ // Invariant: Keep merchant's exact orderId untouched so webhooks, reconciliations, and reporting align.
173
+ body.orderId = body.orderId.replace(/-R\d+$/, '');
174
+ }
175
+
176
+ let amountPaise;
177
+ try {
178
+ amountPaise = toPaise(body.amount);
179
+ } catch (err) {
180
+ res.status(400).json({ error: err.message });
181
+ return;
182
+ }
183
+
184
+ const isCounterPayment = body.method === 'CASH_COUNTER' || body.method === 'CARD_COUNTER';
185
+
186
+ let receivingAccountId = null;
187
+ let receivingAccountSnapshot = null;
188
+ if (!isCounterPayment) {
189
+ // Resolve active receiving account for UPI
190
+ const receivingAccount = ctx.accountRepo.findActiveForMerchant(merchant.id);
191
+ if (!receivingAccount) {
192
+ res.status(500).json({ error: 'No active UPI receiving account configured for merchant.' });
193
+ return;
194
+ }
195
+ receivingAccountId = receivingAccount.id;
196
+ receivingAccountSnapshot = {
197
+ id: receivingAccount.id,
198
+ upiId: receivingAccount.upiId,
199
+ payeeName: receivingAccount.payeeName,
200
+ isMerchantEnabled: receivingAccount.isMerchantEnabled
201
+ };
202
+ }
203
+
204
+ // Resolve customer if provided (optional pre-existing customer)
205
+ let customerId = body.customerId || null;
206
+ if (!customerId && (body.customer?.mobileNumber || body.customer?.phoneNumber || body.customer?.email) && ctx.customerRepo) {
207
+ const ident = body.customer.email || body.customer.mobileNumber || body.customer.phoneNumber;
208
+ const existingCust = ctx.customerRepo.findByIdentifier(ident);
209
+ if (existingCust) {
210
+ customerId = existingCust.id;
211
+ }
212
+ }
213
+
214
+ // Server-side Ban Enforcement (Section 3)
215
+ if (customerId && ctx.customerRepo && ctx.customerRepo.isBanned(customerId)) {
216
+ res.status(403).json({
217
+ status: 'BLOCKED',
218
+ error: 'ACCESS BLOCKED: Your account has been temporarily blocked because suspicious payment activity was detected. Our team will review the activity before access can be restored.',
219
+ message: 'Your account has been temporarily blocked because suspicious payment activity was detected. Our team will review the activity before access can be restored.',
220
+ isBanned: true
221
+ });
222
+ return;
223
+ }
224
+
225
+ // Check if customer already has an active payment session on another device (CASE 3)
226
+ if (customerId && ctx.sessionRepo) {
227
+ const activeForCust = ctx.sessionRepo.findActiveForCustomer ? ctx.sessionRepo.findActiveForCustomer(customerId) : null;
228
+ if (activeForCust) {
229
+ if (activeForCust.deviceId && (!deviceId || deviceId !== activeForCust.deviceId)) {
230
+ res.status(409).json({
231
+ error: 'DUPLICATE_SESSION_DETECTED',
232
+ code: 'DEVICE_SESSION_MISMATCH',
233
+ session_id: activeForCust.id,
234
+ activeSession: {
235
+ paymentId: activeForCust.id,
236
+ orderId: activeForCust.orderId,
237
+ amount: toRupeesString(activeForCust.amountPaise)
238
+ },
239
+ action: 'END_SESSION_AND_CREATE_NEW',
240
+ message: 'This customer already has an active payment session on another device. For security reasons, this session cannot be continued from this device.'
241
+ });
242
+ return;
243
+ }
244
+ }
245
+ }
246
+
247
+ // Invariant: Do NOT start payment session timer until customer is authenticated/logged in!
248
+ // Customer sign in or sign up in the checkout portal is mandatory before slot activation or timer start.
249
+ // Digital UPI payments MUST ALWAYS start in status INITIATED without acquiring a concurrency slot or timer.
250
+ // Only physical counter payments (CASH_COUNTER, CARD_COUNTER) start as ACTIVE (they do not use UPI slots).
251
+ // The only exception for UPI auto-activation is explicit developer test suite flag: body.autoActivate === true.
252
+ const isExplicitTestAutoActivate = body.autoActivate === true || body.autoActivate === 'true';
253
+ const shouldActivateNow = isCounterPayment || isExplicitTestAutoActivate;
254
+ const initialStatus = isCounterPayment ? PaymentStatus.ACTIVE : PaymentStatus.INITIATED;
255
+
256
+ const sessionMetadata = {
257
+ ...(body.metadata || {}),
258
+ ...(receivingAccountSnapshot ? { receivingAccountSnapshot } : {})
259
+ };
260
+
261
+ if (deviceId && customerId && ctx.deviceRepo) {
262
+ try { ctx.deviceRepo.associateCustomer(deviceId, customerId); } catch {}
263
+ }
264
+
265
+ // Create session in DB
266
+ const session = ctx.sessionRepo.create({
267
+ merchantId: merchant.id,
268
+ customerId,
269
+ receivingAccountId,
270
+ orderId: body.orderId,
271
+ amountPaise,
272
+ currency: body.currency || 'INR',
273
+ method: body.method || 'UPI',
274
+ status: initialStatus,
275
+ returnUrl: body.returnUrl || null,
276
+ idempotencyKey: idempotencyKey || null,
277
+ deviceId,
278
+ metadata: sessionMetadata
279
+ });
280
+
281
+ // Enqueue or Activate slot via QueueEngine ONLY when counter payment or explicitly requested by automated test runner
282
+ let queueResult = { status: initialStatus, queuePosition: 0, expiresAt: null };
283
+ if (!isCounterPayment && receivingAccountId && shouldActivateNow) {
284
+ queueResult = ctx.queueEngine.enqueueOrActivate(session.id, receivingAccountId);
285
+
286
+ // If activated immediately, start Timer 1 (5 minutes)
287
+ if (queueResult.expiresAt) {
288
+ ctx.timingEngine.startActiveTimer(session.id, queueResult.expiresAt);
289
+ }
290
+ ctx.eventEmitter?.emit?.('sse:admin_counter_events', { status: queueResult.status, sessionId: session.id, orderId: session.orderId, amountPaise: session.amountPaise ? session.amountPaise.toString() : '0' });
291
+ }
292
+
293
+ res.status(201).json({
294
+ paymentId: session.id,
295
+ orderId: session.orderId,
296
+ amount: toRupeesString(amountPaise),
297
+ amountPaise: amountPaise.toString(),
298
+ currency: session.currency,
299
+ status: queueResult.status,
300
+ queuePosition: queueResult.queuePosition,
301
+ checkoutUrl: `${resolveCheckoutBaseUrl(merchant)}/checkout/${session.id}`
302
+ });
303
+ });
304
+
305
+ // GET /api/v1/payments/:id
306
+ router.get('/api/v1/payments/:id', async (req, res) => {
307
+ const session = ctx.sessionRepo.findById(req.params.id);
308
+ if (!session) {
309
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
310
+ return;
311
+ }
312
+
313
+ const publicStatus = isTerminalStatus(session.status)
314
+ ? toPublicOutcome(session.status)
315
+ : session.status;
316
+
317
+ // Fields any payment-id holder may read. Everything else is merchant-private: the raw
318
+ // state machine status, verification internals, merchant metadata and the return URL.
319
+ const publicView = {
320
+ paymentId: session.id,
321
+ orderId: session.orderId,
322
+ amount: toRupeesString(session.amountPaise),
323
+ amountPaise: session.amountPaise.toString(),
324
+ currency: session.currency,
325
+ status: publicStatus,
326
+ method: session.method,
327
+ completedAt: session.completedAt ? session.completedAt.toISOString() : null
328
+ };
329
+
330
+ if (!isPaymentOwner(req, session, ctx)) {
331
+ res.status(200).json(publicView);
332
+ return;
333
+ }
334
+
335
+ res.status(200).json({
336
+ ...publicView,
337
+ internalStatus: session.status,
338
+ verificationLevel: session.verificationLevel || 0,
339
+ verificationSources: session.verificationSources || [],
340
+ verificationTag: session.verificationTag || 'UNVERIFIED',
341
+ returnUrl: session.returnUrl,
342
+ metadata: session.metadata
343
+ });
344
+ });
345
+
346
+ // GET /api/v1/payments/:id/cod (Developer COD Info Endpoint)
347
+ router.get('/api/v1/payments/:id/cod', async (req, res) => {
348
+ const session = ctx.sessionRepo.findById(req.params.id);
349
+ if (!session) {
350
+ res.status(404).json({ error: `Payment session not found: ${req.params.id}` });
351
+ return;
352
+ }
353
+
354
+ // Order ids are merchant-chosen and often sequential, so the delivery contact and metadata
355
+ // are only for the owning merchant (or the gateway); anyone else gets the order status.
356
+ const owner = isPaymentOwner(req, session, ctx);
357
+ let customer = null;
358
+ if (owner && session.customerId && ctx.customerRepo) {
359
+ const c = ctx.customerRepo.findById(session.customerId);
360
+ if (c) {
361
+ const isOwner = isPaymentOwner(req, session, ctx);
362
+ customer = {
363
+ id: c.id,
364
+ name: c.name,
365
+ email: isOwner ? c.email : (c.email ? `${c.email.slice(0, 2)}***@${c.email.split('@')[1] || '***'}` : null),
366
+ mobileNumber: isOwner ? c.mobileNumber : (c.mobileNumber ? `${c.mobileNumber.slice(0, 2)}******${c.mobileNumber.slice(-2)}` : null)
367
+ };
368
+ }
369
+ }
370
+
371
+ res.status(200).json({
372
+ paymentId: session.id,
373
+ orderId: session.orderId,
374
+ amount: toRupeesString(session.amountPaise),
375
+ amountPaise: session.amountPaise.toString(),
376
+ currency: session.currency,
377
+ method: session.method,
378
+ status: session.status,
379
+ verificationTag: session.verificationTag,
380
+ isCod: session.method === 'COD',
381
+ customer: owner ? customer : null,
382
+ placedAt: session.completedAt ? (session.completedAt.toISOString ? session.completedAt.toISOString() : session.completedAt) : (session.createdAt?.toISOString ? session.createdAt.toISOString() : session.createdAt),
383
+ ...(owner ? { metadata: session.metadata } : {})
384
+ });
385
+ });
386
+
387
+ // GET /api/v1/orders/:orderId/cod (Developer COD Info by Merchant Order ID)
388
+ router.get('/api/v1/orders/:orderId/cod', async (req, res) => {
389
+ const merchantId = req.headers['x-merchant-id'] || req.query.merchantId;
390
+ let merchant;
391
+ if (merchantId) {
392
+ merchant = ctx.merchantRepo.findById(merchantId);
393
+ if (!merchant) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : null;
394
+ } else {
395
+ merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
396
+ }
397
+
398
+ if (!merchant) {
399
+ res.status(404).json({ error: 'Merchant not found.' });
400
+ return;
401
+ }
402
+
403
+ const session = ctx.sessionRepo.findByOrderId(merchant.id, req.params.orderId);
404
+ if (!session) {
405
+ res.status(404).json({ error: `Order not found: ${req.params.orderId}` });
406
+ return;
407
+ }
408
+
409
+ // Order ids are merchant-chosen and often sequential, so the delivery contact and metadata
410
+ // are only for the owning merchant (or the gateway); anyone else gets the order status.
411
+ const owner = isPaymentOwner(req, session, ctx);
412
+ let customer = null;
413
+ if (owner && session.customerId && ctx.customerRepo) {
414
+ const c = ctx.customerRepo.findById(session.customerId);
415
+ if (c) {
416
+ const isOwner = isPaymentOwner(req, session, ctx);
417
+ customer = {
418
+ id: c.id,
419
+ name: c.name,
420
+ email: isOwner ? c.email : (c.email ? `${c.email.slice(0, 2)}***@${c.email.split('@')[1] || '***'}` : null),
421
+ mobileNumber: isOwner ? c.mobileNumber : (c.mobileNumber ? `${c.mobileNumber.slice(0, 2)}******${c.mobileNumber.slice(-2)}` : null)
422
+ };
423
+ }
424
+ }
425
+
426
+ res.status(200).json({
427
+ paymentId: session.id,
428
+ orderId: session.orderId,
429
+ amount: toRupeesString(session.amountPaise),
430
+ amountPaise: session.amountPaise.toString(),
431
+ currency: session.currency,
432
+ method: session.method,
433
+ status: session.status,
434
+ verificationTag: session.verificationTag,
435
+ isCod: session.method === 'COD',
436
+ customer: owner ? customer : null,
437
+ placedAt: session.completedAt ? (session.completedAt.toISOString ? session.completedAt.toISOString() : session.completedAt) : (session.createdAt?.toISOString ? session.createdAt.toISOString() : session.createdAt),
438
+ ...(owner ? { metadata: session.metadata } : {})
439
+ });
440
+ });
441
+ }
442
+
443
+
444
+
445
+
446
+