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.
- 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 +2421 -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,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
|
+
|