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,280 @@
1
+ import crypto from 'node:crypto';
2
+ import { generateHmac, toRupeesString, validateOutboundWebhookUrl } from '../common/index.js';
3
+ import { isTerminalStatus, toPublicOutcome } from './state-machine.js';
4
+
5
+ export class WebhookEngine {
6
+ /**
7
+ * @param {{
8
+ * db: import('../db/index.js').Database,
9
+ * merchantRepo: import('../db/index.js').MerchantRepository,
10
+ * maxRetries?: number
11
+ * }} options
12
+ */
13
+ constructor(options) {
14
+ this.db = options.db;
15
+ this.merchantRepo = options.merchantRepo;
16
+ this.maxRetries = options.maxRetries || 5;
17
+ }
18
+
19
+ /**
20
+ * Formats and dispatches a signed webhook for a payment event.
21
+ * @param {string} event E.g. 'SUCCESS', 'FAILED', 'EXPIRED', 'CANCELLED'
22
+ * @param {any} session The payment session entity
23
+ * @returns {Promise<{ delivered: boolean, deliveryId: string, status?: number, error?: string }>}
24
+ */
25
+ async dispatch(event, session) {
26
+ const merchant = this.merchantRepo.findById(session.merchantId);
27
+ if (!merchant || !merchant.webhookUrl) {
28
+ return { delivered: false, deliveryId: '', error: 'No webhook URL configured for merchant.' };
29
+ }
30
+
31
+ const publicOutcome = isTerminalStatus(event) ? toPublicOutcome(event) : event;
32
+ const nowIso = new Date().toISOString();
33
+
34
+ // 1. Up-front secret validation: if webhookSecret is missing, persist a failed row so it is auditable
35
+ if (!merchant.webhookSecret) {
36
+ const deliveryId = crypto.randomUUID();
37
+ this.db.execute(`
38
+ INSERT INTO webhook_deliveries (
39
+ id, merchant_id, payment_id, event_type, payload, endpoint_url,
40
+ response_status, response_body, attempt_count, is_successful, next_retry_at, created_at
41
+ ) VALUES (?, ?, ?, ?, ?, ?, 500, 'Merchant webhookSecret is not configured.', 1, 0, NULL, ?)
42
+ `, [
43
+ deliveryId,
44
+ merchant.id,
45
+ session.id,
46
+ publicOutcome,
47
+ JSON.stringify({ error: 'Missing webhookSecret', paymentId: session.id, orderId: session.orderId }),
48
+ merchant.webhookUrl,
49
+ nowIso
50
+ ]);
51
+ return { delivered: false, deliveryId, status: 500, error: 'Merchant webhookSecret is not configured.' };
52
+ }
53
+
54
+ // 2. Re-validate at dispatch time (SSRF guard)
55
+ const endpointCheck = validateOutboundWebhookUrl(merchant.webhookUrl);
56
+ if (!endpointCheck.valid) {
57
+ const deliveryId = crypto.randomUUID();
58
+ this.db.execute(`
59
+ INSERT INTO webhook_deliveries (
60
+ id, merchant_id, payment_id, event_type, payload, endpoint_url,
61
+ response_status, response_body, attempt_count, is_successful, next_retry_at, created_at
62
+ ) VALUES (?, ?, ?, ?, ?, ?, 400, ?, 1, 0, NULL, ?)
63
+ `, [
64
+ deliveryId,
65
+ merchant.id,
66
+ session.id,
67
+ publicOutcome,
68
+ JSON.stringify({ error: 'SSRF blocked', paymentId: session.id, orderId: session.orderId }),
69
+ merchant.webhookUrl,
70
+ `Webhook delivery blocked: ${endpointCheck.error}`,
71
+ nowIso
72
+ ]);
73
+ return { delivered: false, deliveryId, status: 400, error: `Webhook delivery blocked: ${endpointCheck.error}` };
74
+ }
75
+
76
+ // 3. Resolve UTR / bank reference number if attributed
77
+ // The bank reference (UTR/RRN) lives on the attributed observation, not on the
78
+ // session row. Merchants need it to reconcile a webhook against their bank
79
+ // statement, and it is part of the documented webhook contract.
80
+ let utr = session.utr || session.bankReferenceNumber || session.bank_reference_number || '';
81
+ if (!utr && session.attributedObservationId) {
82
+ try {
83
+ const obsRow = this.db.queryOne(
84
+ 'SELECT bank_reference_number FROM payment_observations WHERE id = ?',
85
+ [session.attributedObservationId]
86
+ );
87
+ if (obsRow?.bank_reference_number) utr = obsRow.bank_reference_number;
88
+ } catch {}
89
+ }
90
+
91
+ const payloadObj = {
92
+ event: publicOutcome,
93
+ paymentId: session.id,
94
+ orderId: session.orderId,
95
+ attemptId: session.attemptId || session.id,
96
+ amount: toRupeesString(session.amountPaise),
97
+ amountPaise: Number(session.amountPaise),
98
+ currency: session.currency || 'INR',
99
+ method: session.method || 'UPI',
100
+ utr: String(utr || ''),
101
+ verificationTag: session.verificationTag || session.verification_tag || (session.method === 'COD' ? 'COD_ORDER_PLACED' : 'UNVERIFIED'),
102
+ verificationLevel: session.verificationLevel != null ? Number(session.verificationLevel) : (session.verification_level != null ? Number(session.verification_level) : (session.method === 'COD' ? 1 : 0)),
103
+ completedAt: session.completedAt ? (typeof session.completedAt.toISOString === 'function' ? session.completedAt.toISOString() : String(session.completedAt)) : new Date().toISOString(),
104
+ metadata: session.metadata || {}
105
+ };
106
+
107
+ const payloadString = JSON.stringify(payloadObj);
108
+ const signature = generateHmac(payloadString, merchant.webhookSecret);
109
+ const deliveryId = crypto.randomUUID();
110
+ const timestamp = Date.now().toString();
111
+
112
+ const headers = {
113
+ 'Content-Type': 'application/json',
114
+ 'X-Gateway-Event': publicOutcome,
115
+ 'X-Gateway-Signature': `sha256=${signature}`,
116
+ 'X-Gateway-Delivery-Id': deliveryId,
117
+ 'X-Gateway-Timestamp': timestamp
118
+ };
119
+
120
+ let responseStatus = null;
121
+ let responseBody = null;
122
+ let isSuccessful = false;
123
+
124
+ try {
125
+ const res = await fetch(endpointCheck.url.toString(), {
126
+ method: 'POST',
127
+ headers,
128
+ body: payloadString,
129
+ // Never follow redirects: a public URL could otherwise 302 into internal network (SSRF)
130
+ redirect: 'manual',
131
+ signal: AbortSignal.timeout(10000) // 10s timeout
132
+ });
133
+
134
+ responseStatus = res.status;
135
+ responseBody = (await res.text()).substring(0, 1000);
136
+ isSuccessful = res.ok;
137
+ } catch (err) {
138
+ responseBody = err.message || 'Network timeout';
139
+ }
140
+
141
+ // Persist delivery record in webhook_deliveries
142
+ const nextRetryAt = isSuccessful
143
+ ? null
144
+ : new Date(Date.now() + 5000).toISOString(); // initial 5s backoff
145
+
146
+ this.db.execute(`
147
+ INSERT INTO webhook_deliveries (
148
+ id, merchant_id, payment_id, event_type, payload, endpoint_url,
149
+ response_status, response_body, attempt_count, is_successful, next_retry_at, created_at
150
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
151
+ `, [
152
+ deliveryId,
153
+ merchant.id,
154
+ session.id,
155
+ publicOutcome,
156
+ payloadString,
157
+ merchant.webhookUrl,
158
+ responseStatus,
159
+ responseBody,
160
+ isSuccessful ? 1 : 0,
161
+ nextRetryAt,
162
+ nowIso
163
+ ]);
164
+
165
+ return {
166
+ delivered: isSuccessful,
167
+ deliveryId,
168
+ status: responseStatus,
169
+ error: isSuccessful ? undefined : responseBody
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Retries overdue pending webhook deliveries with atomic claim & exponential backoff.
175
+ * Backoff strategy: 5s, 30s, 2m, 10m, 1h
176
+ */
177
+ async retryPending() {
178
+ const nowIso = new Date().toISOString();
179
+
180
+ // Invariant: Atomically claim overdue pending deliveries in a transaction to prevent duplicate dispatches
181
+ const claimed = this.db.transaction(() => {
182
+ const pending = this.db.query(`
183
+ SELECT id, merchant_id, payment_id, event_type, payload, endpoint_url, attempt_count
184
+ FROM webhook_deliveries
185
+ WHERE is_successful = 0 AND attempt_count < ? AND next_retry_at <= ?
186
+ LIMIT 20
187
+ `, [this.maxRetries, nowIso]);
188
+
189
+ if (pending.length === 0) return [];
190
+
191
+ // Lock claimed items by setting next_retry_at 60 seconds into the future
192
+ const claimLockIso = new Date(Date.now() + 60000).toISOString();
193
+ for (const item of pending) {
194
+ this.db.execute(`
195
+ UPDATE webhook_deliveries
196
+ SET next_retry_at = ?, attempt_count = attempt_count + 1
197
+ WHERE id = ?
198
+ `, [claimLockIso, item.id]);
199
+ }
200
+
201
+ return pending;
202
+ });
203
+
204
+ if (claimed.length === 0) return [];
205
+
206
+ const results = [];
207
+ const backoffs = [5000, 30000, 120000, 600000, 3600000];
208
+
209
+ for (const item of claimed) {
210
+ const merchant = this.merchantRepo.findById(item.merchant_id);
211
+ if (!merchant || !merchant.webhookSecret || !item.endpoint_url) {
212
+ this.db.execute(`
213
+ UPDATE webhook_deliveries
214
+ SET is_successful = 0, next_retry_at = NULL, response_body = 'Missing merchant or webhookSecret'
215
+ WHERE id = ?
216
+ `, [item.id]);
217
+ continue;
218
+ }
219
+
220
+ // Re-validate the stored endpoint before every retry (SSRF guard).
221
+ const retryEndpoint = validateOutboundWebhookUrl(item.endpoint_url);
222
+ if (!retryEndpoint.valid) {
223
+ this.db.execute(`
224
+ UPDATE webhook_deliveries
225
+ SET is_successful = 0, next_retry_at = NULL, response_body = ?
226
+ WHERE id = ?
227
+ `, [`SSRF blocked: ${retryEndpoint.error}`, item.id]);
228
+ continue;
229
+ }
230
+
231
+ const timestamp = Date.now().toString();
232
+ // The signature must cover exactly the transmitted body, using the same formula as
233
+ // dispatch(). Signing `${timestamp}.${payload}` here meant a retried delivery carried a
234
+ // different MAC than the first attempt, so the merchant's verification (HMAC over the
235
+ // raw body, per the documented contract) rejected every retry.
236
+ const signature = generateHmac(item.payload, merchant.webhookSecret);
237
+
238
+ let isSuccess = false;
239
+ let respStatus = null;
240
+ let respBody = null;
241
+
242
+ try {
243
+ const res = await fetch(retryEndpoint.url.toString(), {
244
+ method: 'POST',
245
+ headers: {
246
+ 'Content-Type': 'application/json',
247
+ 'X-Gateway-Event': item.event_type,
248
+ 'X-Gateway-Signature': `sha256=${signature}`,
249
+ 'X-Gateway-Delivery-Id': item.id,
250
+ 'X-Gateway-Timestamp': timestamp
251
+ },
252
+ body: item.payload,
253
+ redirect: 'manual',
254
+ signal: AbortSignal.timeout(10000)
255
+ });
256
+ respStatus = res.status;
257
+ respBody = (await res.text()).substring(0, 1000);
258
+ isSuccess = res.ok;
259
+ } catch (err) {
260
+ respBody = err.message;
261
+ }
262
+
263
+ const nextAttempt = item.attempt_count + 1; // item.attempt_count was already incremented during claim
264
+ const nextRetryDelay = backoffs[Math.min(nextAttempt - 1, backoffs.length - 1)];
265
+ const nextRetryAt = isSuccess || nextAttempt >= this.maxRetries
266
+ ? null
267
+ : new Date(Date.now() + nextRetryDelay).toISOString();
268
+
269
+ this.db.execute(`
270
+ UPDATE webhook_deliveries
271
+ SET is_successful = ?, response_status = ?, response_body = ?, next_retry_at = ?
272
+ WHERE id = ?
273
+ `, [isSuccess ? 1 : 0, respStatus, respBody, nextRetryAt, item.id]);
274
+
275
+ results.push({ id: item.id, success: isSuccess, attempt: nextAttempt });
276
+ }
277
+
278
+ return results;
279
+ }
280
+ }