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,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
|
+
}
|