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,297 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
buildCloudflaredInvocation,
|
|
5
|
+
safeEqual,
|
|
6
|
+
toPaise,
|
|
7
|
+
toRupeesString,
|
|
8
|
+
formatINR,
|
|
9
|
+
encrypt,
|
|
10
|
+
decrypt,
|
|
11
|
+
generateHmac,
|
|
12
|
+
verifyHmac,
|
|
13
|
+
hashPassword,
|
|
14
|
+
verifyPassword,
|
|
15
|
+
isValidVpa,
|
|
16
|
+
buildUpiUri,
|
|
17
|
+
buildUpiIntentUri,
|
|
18
|
+
UPI_PROVIDERS,
|
|
19
|
+
getUpiProviders,
|
|
20
|
+
findUpiProviderByPackage,
|
|
21
|
+
findUpiProviderById,
|
|
22
|
+
ALLOWED_NOTIFICATION_PACKAGES,
|
|
23
|
+
isAllowedNotificationPackage,
|
|
24
|
+
calculateObservationFingerprint
|
|
25
|
+
} from '../index.js';
|
|
26
|
+
|
|
27
|
+
describe('../../common/index.js Unit Tests', () => {
|
|
28
|
+
describe('Money and Currency Utilities', () => {
|
|
29
|
+
test('converts valid rupee strings and numbers to integer paise', () => {
|
|
30
|
+
assert.equal(toPaise('200.00'), 20000n);
|
|
31
|
+
assert.equal(toPaise('200'), 20000n);
|
|
32
|
+
assert.equal(toPaise(200), 20000n);
|
|
33
|
+
assert.equal(toPaise('0.50'), 50n);
|
|
34
|
+
assert.equal(toPaise('0.05'), 5n);
|
|
35
|
+
assert.equal(toPaise('199.99'), 19999n);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects negative, zero, and malformed currency inputs', () => {
|
|
39
|
+
assert.throws(() => toPaise('0.00'), /greater than 0/);
|
|
40
|
+
assert.throws(() => toPaise('-50.00'), /Invalid INR rupee format/);
|
|
41
|
+
assert.throws(() => toPaise('12.345'), /Invalid INR rupee format/);
|
|
42
|
+
assert.throws(() => toPaise('abc'), /Invalid INR rupee format/);
|
|
43
|
+
assert.throws(() => toPaise(NaN), /Invalid monetary amount/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('formats paise into standard decimal string and INR symbol', () => {
|
|
47
|
+
assert.equal(toRupeesString(20000n), '200.00');
|
|
48
|
+
assert.equal(toRupeesString(50n), '0.50');
|
|
49
|
+
assert.equal(toRupeesString(5n), '0.05');
|
|
50
|
+
assert.equal(formatINR(20000n), '₹200.00');
|
|
51
|
+
assert.equal(formatINR(19999n), '₹199.99');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('Cryptographic Helpers', () => {
|
|
56
|
+
const testMasterKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
|
57
|
+
|
|
58
|
+
test('encrypts and decrypts with AES-256-GCM successfully', () => {
|
|
59
|
+
const original = 'super-secret-oauth-token-12345';
|
|
60
|
+
const encrypted = encrypt(original, testMasterKey);
|
|
61
|
+
assert.notEqual(encrypted, original);
|
|
62
|
+
assert.equal(encrypted.split(':').length, 3);
|
|
63
|
+
|
|
64
|
+
const decrypted = decrypt(encrypted, testMasterKey);
|
|
65
|
+
assert.equal(decrypted, original);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('fails decryption if ciphertext or auth tag is tampered', () => {
|
|
69
|
+
const encrypted = encrypt('sensitive data', testMasterKey);
|
|
70
|
+
const parts = encrypted.split(':');
|
|
71
|
+
const tampered = `${parts[0]}:${parts[1]}:${parts[2]}ab`;
|
|
72
|
+
assert.throws(() => decrypt(tampered, testMasterKey));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('generates and verifies HMAC-SHA256 signatures with timing safety', () => {
|
|
76
|
+
const secret = 'merchant_webhook_secret_xyz';
|
|
77
|
+
const payload = JSON.stringify({ event: 'SUCCESS', paymentId: 'p123' });
|
|
78
|
+
const signature = generateHmac(payload, secret);
|
|
79
|
+
|
|
80
|
+
assert.equal(verifyHmac(payload, secret, signature), true);
|
|
81
|
+
assert.equal(verifyHmac(payload, 'wrong_secret', signature), false);
|
|
82
|
+
assert.equal(verifyHmac('altered payload', secret, signature), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('hashes and verifies passwords securely', () => {
|
|
86
|
+
const pass = 'CorrectHorseBatteryStaple!42';
|
|
87
|
+
const hash = hashPassword(pass);
|
|
88
|
+
assert.ok(hash.startsWith('100000:'));
|
|
89
|
+
|
|
90
|
+
assert.equal(verifyPassword(pass, hash), true);
|
|
91
|
+
assert.equal(verifyPassword('WrongPassword', hash), false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('UPI Utilities', () => {
|
|
96
|
+
test('validates UPI VPAs correctly', () => {
|
|
97
|
+
assert.equal(isValidVpa('merchant@icici'), true);
|
|
98
|
+
assert.equal(isValidVpa('user.name-12@okhdfcbank'), true);
|
|
99
|
+
assert.equal(isValidVpa('invalid-vpa-no-bank'), false);
|
|
100
|
+
assert.equal(isValidVpa('@bank'), false);
|
|
101
|
+
assert.equal(isValidVpa(''), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('builds canonical upi://pay URI dynamically with server amount', () => {
|
|
105
|
+
const uri = buildUpiUri({
|
|
106
|
+
payeeVpa: 'store@upi',
|
|
107
|
+
payeeName: 'Acme Store',
|
|
108
|
+
amountPaise: 20000n,
|
|
109
|
+
transactionRef: 'ORDER-99',
|
|
110
|
+
transactionNote: 'Order payment'
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.ok(uri.startsWith('upi://pay?'));
|
|
114
|
+
const parsed = new URL(uri);
|
|
115
|
+
assert.equal(parsed.searchParams.get('pa'), 'store@upi');
|
|
116
|
+
assert.equal(parsed.searchParams.get('pn'), 'Acme Store');
|
|
117
|
+
assert.equal(parsed.searchParams.get('am'), '200.00');
|
|
118
|
+
assert.equal(parsed.searchParams.get('tn'), 'ORDER-99');
|
|
119
|
+
assert.equal(parsed.searchParams.get('tr'), null);
|
|
120
|
+
// Verify no extra noisy parameters
|
|
121
|
+
assert.equal(parsed.searchParams.get('cu'), null);
|
|
122
|
+
assert.deepEqual([...parsed.searchParams.keys()], ['pa', 'pn', 'am', 'tn']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('strictly excludes cu, tr and all extra parameters even when currency option is passed', () => {
|
|
126
|
+
const uri = buildUpiUri({
|
|
127
|
+
payeeVpa: '9876543210@axl',
|
|
128
|
+
payeeName: 'Demo Store Merchant',
|
|
129
|
+
amountPaise: 100n,
|
|
130
|
+
transactionRef: 'ORDER-123',
|
|
131
|
+
currency: 'INR'
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.ok(uri.startsWith('upi://pay?'));
|
|
135
|
+
const parsed = new URL(uri);
|
|
136
|
+
assert.equal(parsed.searchParams.get('pa'), '9876543210@axl');
|
|
137
|
+
assert.equal(parsed.searchParams.get('pn'), 'Demo Store Merchant');
|
|
138
|
+
assert.equal(parsed.searchParams.get('am'), '1.00');
|
|
139
|
+
assert.equal(parsed.searchParams.get('tn'), 'ORDER-123');
|
|
140
|
+
// Strictly verify NO cu, NO tr, NO extra parameters
|
|
141
|
+
assert.equal(parsed.searchParams.get('cu'), null);
|
|
142
|
+
assert.equal(parsed.searchParams.get('tr'), null);
|
|
143
|
+
assert.deepEqual([...parsed.searchParams.keys()], ['pa', 'pn', 'am', 'tn']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('builds valid Android intent URI containing only canonical UPI parameters', () => {
|
|
147
|
+
const intentUri = buildUpiIntentUri({
|
|
148
|
+
payeeVpa: '9876543210@axl',
|
|
149
|
+
payeeName: 'Demo Store Merchant',
|
|
150
|
+
amountPaise: 100n,
|
|
151
|
+
transactionRef: 'ORDER-123',
|
|
152
|
+
currency: 'INR',
|
|
153
|
+
packageName: 'com.phonepe.app'
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
assert.ok(intentUri.startsWith('intent://pay?'));
|
|
157
|
+
assert.ok(intentUri.includes('package=com.phonepe.app'));
|
|
158
|
+
assert.ok(intentUri.includes('scheme=upi'));
|
|
159
|
+
assert.ok(intentUri.endsWith('#Intent;scheme=upi;package=com.phonepe.app;end'));
|
|
160
|
+
// Underlying query payload inside intent must not have cu or tr
|
|
161
|
+
const queryPart = intentUri.split('#')[0].replace('intent://pay?', '');
|
|
162
|
+
const queryParams = new URLSearchParams(queryPart);
|
|
163
|
+
assert.equal(queryParams.get('cu'), null);
|
|
164
|
+
assert.equal(queryParams.get('tr'), null);
|
|
165
|
+
assert.deepEqual([...queryParams.keys()], ['pa', 'pn', 'am', 'tn']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('validates 30 official UPI application provider registry', () => {
|
|
169
|
+
const providers = getUpiProviders();
|
|
170
|
+
assert.equal(providers.length, 30, 'Must have exactly 30 registered UPI providers');
|
|
171
|
+
|
|
172
|
+
// Verify Google Pay, PhonePe, Paytm packages
|
|
173
|
+
const gpay = findUpiProviderByPackage('com.google.android.apps.nbu.paisa.user');
|
|
174
|
+
assert.ok(gpay);
|
|
175
|
+
assert.equal(gpay.name, 'Google Pay');
|
|
176
|
+
assert.ok(gpay.iconUrl.includes('play-lh.googleusercontent.com'));
|
|
177
|
+
assert.ok(gpay.svgLogo.includes('<svg'));
|
|
178
|
+
|
|
179
|
+
const phonepe = findUpiProviderById('phonepe');
|
|
180
|
+
assert.ok(phonepe);
|
|
181
|
+
assert.equal(phonepe.packageName, 'com.phonepe.app');
|
|
182
|
+
|
|
183
|
+
const paytm = findUpiProviderById('paytm');
|
|
184
|
+
assert.ok(paytm);
|
|
185
|
+
assert.equal(paytm.packageName, 'net.one97.paytm');
|
|
186
|
+
|
|
187
|
+
// Verify all 30 providers have unique packages, names, and logos
|
|
188
|
+
const packages = new Set();
|
|
189
|
+
for (const p of providers) {
|
|
190
|
+
assert.ok(p.id, `Provider must have an id`);
|
|
191
|
+
assert.ok(p.name, `Provider must have a name`);
|
|
192
|
+
assert.ok(p.packageName, `Provider ${p.name} must have a packageName`);
|
|
193
|
+
assert.ok(p.iconUrl, `Provider ${p.name} must have an iconUrl`);
|
|
194
|
+
assert.ok(p.svgLogo, `Provider ${p.name} must have an svgLogo`);
|
|
195
|
+
assert.ok(!packages.has(p.packageName), `Duplicate package name: ${p.packageName}`);
|
|
196
|
+
packages.add(p.packageName);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('validates unified ALLOWED_NOTIFICATION_PACKAGES and isAllowedNotificationPackage', () => {
|
|
201
|
+
assert.ok(Array.isArray(ALLOWED_NOTIFICATION_PACKAGES));
|
|
202
|
+
assert.ok(ALLOWED_NOTIFICATION_PACKAGES.length >= 38);
|
|
203
|
+
|
|
204
|
+
// Verify essential UPI and banking packages are included
|
|
205
|
+
assert.equal(isAllowedNotificationPackage('com.phonepe.app'), true);
|
|
206
|
+
assert.equal(isAllowedNotificationPackage('com.google.android.apps.nbu.paisa.user'), true);
|
|
207
|
+
assert.equal(isAllowedNotificationPackage('net.one97.paytm'), true);
|
|
208
|
+
assert.equal(isAllowedNotificationPackage('com.google.android.apps.messaging'), true);
|
|
209
|
+
assert.equal(isAllowedNotificationPackage('com.sbi.lotusintouch'), true);
|
|
210
|
+
|
|
211
|
+
// Case-insensitivity and trim handling
|
|
212
|
+
assert.equal(isAllowedNotificationPackage(' COM.PHONEPE.APP '), true);
|
|
213
|
+
|
|
214
|
+
// Rejects spoofed or unlisted apps
|
|
215
|
+
assert.equal(isAllowedNotificationPackage('com.attacker.fake.app'), false);
|
|
216
|
+
assert.equal(isAllowedNotificationPackage(''), false);
|
|
217
|
+
assert.equal(isAllowedNotificationPackage(null), false);
|
|
218
|
+
assert.equal(isAllowedNotificationPackage(undefined), false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('Observation Fingerprinting', () => {
|
|
223
|
+
test('generates consistent fingerprints with bank reference numbers', () => {
|
|
224
|
+
const fp1 = calculateObservationFingerprint({
|
|
225
|
+
receivingAccountId: 'acc_1',
|
|
226
|
+
amountPaise: 20000n,
|
|
227
|
+
bankReferenceNumber: 'UTR12345678',
|
|
228
|
+
observedAt: new Date('2026-09-08T10:00:00Z')
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const fp2 = calculateObservationFingerprint({
|
|
232
|
+
receivingAccountId: 'acc_1',
|
|
233
|
+
amountPaise: 20000n,
|
|
234
|
+
bankReferenceNumber: 'UTR12345678',
|
|
235
|
+
observedAt: new Date('2026-09-08T10:02:00Z')
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
assert.equal(fp1, fp2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('generates cluster fingerprints within same 60-second window when no reference', () => {
|
|
242
|
+
const fp1 = calculateObservationFingerprint({
|
|
243
|
+
receivingAccountId: 'acc_1',
|
|
244
|
+
amountPaise: 20000n,
|
|
245
|
+
observedAt: new Date('2026-09-08T10:00:10Z')
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const fp2 = calculateObservationFingerprint({
|
|
249
|
+
receivingAccountId: 'acc_1',
|
|
250
|
+
amountPaise: 20000n,
|
|
251
|
+
observedAt: new Date('2026-09-08T10:00:45Z')
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
assert.equal(fp1, fp2);
|
|
255
|
+
|
|
256
|
+
const fpDiffTime = calculateObservationFingerprint({
|
|
257
|
+
receivingAccountId: 'acc_1',
|
|
258
|
+
amountPaise: 20000n,
|
|
259
|
+
observedAt: new Date('2026-09-08T10:05:00Z')
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
assert.notEqual(fp1, fpDiffTime);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('buildCloudflaredInvocation', () => {
|
|
268
|
+
const token = 'eyJhIjoiMDEyMzQ1Njc4OSIsInQiOiJhYmNkZWYifQ==';
|
|
269
|
+
|
|
270
|
+
test('extracts the token from pasted cloudflared commands and never returns the pasted text', () => {
|
|
271
|
+
for (const cmd of [
|
|
272
|
+
`cloudflared.exe service install ${token}`,
|
|
273
|
+
`cloudflared tunnel run --token ${token}`,
|
|
274
|
+
token
|
|
275
|
+
]) {
|
|
276
|
+
const inv = buildCloudflaredInvocation(cmd);
|
|
277
|
+
assert.equal(inv.file, 'cloudflared');
|
|
278
|
+
assert.deepEqual(inv.args, ['tunnel', '--no-autoupdate', 'run', '--token', token]);
|
|
279
|
+
assert.ok(!inv.redacted.includes(token));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('rejects commands without a token, including shell payloads', () => {
|
|
284
|
+
assert.throws(() => buildCloudflaredInvocation(''), /empty/);
|
|
285
|
+
assert.throws(() => buildCloudflaredInvocation('calc.exe & whoami'), /No valid Cloudflare tunnel token/);
|
|
286
|
+
assert.throws(() => buildCloudflaredInvocation('curl http://x | sh'), /No valid Cloudflare tunnel token/);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('safeEqual', () => {
|
|
291
|
+
test('compares strings in constant time and rejects non-strings', () => {
|
|
292
|
+
assert.equal(safeEqual('abc', 'abc'), true);
|
|
293
|
+
assert.equal(safeEqual('abc', 'abd'), false);
|
|
294
|
+
assert.equal(safeEqual('abc', 'abcd'), false);
|
|
295
|
+
assert.equal(safeEqual(undefined, 'abc'), false);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
toPaise,
|
|
5
|
+
toRupeesString,
|
|
6
|
+
formatINR,
|
|
7
|
+
encrypt,
|
|
8
|
+
decrypt,
|
|
9
|
+
generateHmac,
|
|
10
|
+
verifyHmac,
|
|
11
|
+
hashPassword,
|
|
12
|
+
verifyPassword,
|
|
13
|
+
isValidVpa,
|
|
14
|
+
buildUpiUri,
|
|
15
|
+
calculateObservationFingerprint
|
|
16
|
+
} from '../index.js';
|
|
17
|
+
|
|
18
|
+
describe('@elpay/common Unit Tests', () => {
|
|
19
|
+
describe('Money and Currency Utilities', () => {
|
|
20
|
+
test('converts valid rupee strings and numbers to integer paise', () => {
|
|
21
|
+
assert.equal(toPaise('200.00'), 20000n);
|
|
22
|
+
assert.equal(toPaise('200'), 20000n);
|
|
23
|
+
assert.equal(toPaise(200), 20000n);
|
|
24
|
+
assert.equal(toPaise('0.50'), 50n);
|
|
25
|
+
assert.equal(toPaise('0.05'), 5n);
|
|
26
|
+
assert.equal(toPaise('199.99'), 19999n);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('rejects negative, zero, and malformed currency inputs', () => {
|
|
30
|
+
assert.throws(() => toPaise('0.00'), /greater than 0/);
|
|
31
|
+
assert.throws(() => toPaise('-50.00'), /Invalid INR rupee format/);
|
|
32
|
+
assert.throws(() => toPaise('12.345'), /Invalid INR rupee format/);
|
|
33
|
+
assert.throws(() => toPaise('abc'), /Invalid INR rupee format/);
|
|
34
|
+
assert.throws(() => toPaise(NaN), /Invalid monetary amount/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('formats paise into standard decimal string and INR symbol', () => {
|
|
38
|
+
assert.equal(toRupeesString(20000n), '200.00');
|
|
39
|
+
assert.equal(toRupeesString(50n), '0.50');
|
|
40
|
+
assert.equal(toRupeesString(5n), '0.05');
|
|
41
|
+
assert.equal(formatINR(20000n), '₹200.00');
|
|
42
|
+
assert.equal(formatINR(19999n), '₹199.99');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('Cryptographic Helpers', () => {
|
|
47
|
+
const testMasterKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
|
48
|
+
|
|
49
|
+
test('encrypts and decrypts with AES-256-GCM successfully', () => {
|
|
50
|
+
const original = 'super-secret-oauth-token-12345';
|
|
51
|
+
const encrypted = encrypt(original, testMasterKey);
|
|
52
|
+
assert.notEqual(encrypted, original);
|
|
53
|
+
assert.equal(encrypted.split(':').length, 3);
|
|
54
|
+
|
|
55
|
+
const decrypted = decrypt(encrypted, testMasterKey);
|
|
56
|
+
assert.equal(decrypted, original);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('fails decryption if ciphertext or auth tag is tampered', () => {
|
|
60
|
+
const encrypted = encrypt('sensitive data', testMasterKey);
|
|
61
|
+
const parts = encrypted.split(':');
|
|
62
|
+
// Tamper with cipher part
|
|
63
|
+
const tampered = `${parts[0]}:${parts[1]}:${parts[2]}ab`;
|
|
64
|
+
assert.throws(() => decrypt(tampered, testMasterKey));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('generates and verifies HMAC-SHA256 signatures with timing safety', () => {
|
|
68
|
+
const secret = 'merchant_webhook_secret_xyz';
|
|
69
|
+
const payload = JSON.stringify({ event: 'SUCCESS', paymentId: 'p123' });
|
|
70
|
+
const signature = generateHmac(payload, secret);
|
|
71
|
+
|
|
72
|
+
assert.equal(verifyHmac(payload, secret, signature), true);
|
|
73
|
+
assert.equal(verifyHmac(payload, 'wrong_secret', signature), false);
|
|
74
|
+
assert.equal(verifyHmac('altered payload', secret, signature), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('hashes and verifies passwords securely', () => {
|
|
78
|
+
const pass = 'CorrectHorseBatteryStaple!42';
|
|
79
|
+
const hash = hashPassword(pass);
|
|
80
|
+
assert.ok(hash.startsWith('100000:'));
|
|
81
|
+
|
|
82
|
+
assert.equal(verifyPassword(pass, hash), true);
|
|
83
|
+
assert.equal(verifyPassword('WrongPassword', hash), false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('UPI Utilities', () => {
|
|
88
|
+
test('validates UPI VPAs correctly', () => {
|
|
89
|
+
assert.equal(isValidVpa('merchant@icici'), true);
|
|
90
|
+
assert.equal(isValidVpa('user.name-12@okhdfcbank'), true);
|
|
91
|
+
assert.equal(isValidVpa('invalid-vpa-no-bank'), false);
|
|
92
|
+
assert.equal(isValidVpa('@bank'), false);
|
|
93
|
+
assert.equal(isValidVpa(''), false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('builds canonical upi://pay URI dynamically with server amount', () => {
|
|
97
|
+
const uri = buildUpiUri({
|
|
98
|
+
payeeVpa: 'store@upi',
|
|
99
|
+
payeeName: 'Acme Store',
|
|
100
|
+
amountPaise: 20000n,
|
|
101
|
+
transactionRef: 'ORDER-99',
|
|
102
|
+
transactionNote: 'Order payment'
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.ok(uri.startsWith('upi://pay?'));
|
|
106
|
+
const parsed = new URL(uri);
|
|
107
|
+
assert.equal(parsed.searchParams.get('pa'), 'store@upi');
|
|
108
|
+
assert.equal(parsed.searchParams.get('pn'), 'Acme Store');
|
|
109
|
+
assert.equal(parsed.searchParams.get('am'), '200.00');
|
|
110
|
+
assert.equal(parsed.searchParams.get('tn'), 'ORDER-99');
|
|
111
|
+
assert.equal(parsed.searchParams.get('tr'), null);
|
|
112
|
+
assert.equal(parsed.searchParams.get('cu'), null);
|
|
113
|
+
assert.deepEqual([...parsed.searchParams.keys()], ['pa', 'pn', 'am', 'tn']);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Observation Fingerprinting', () => {
|
|
118
|
+
test('generates consistent fingerprints with bank reference numbers', () => {
|
|
119
|
+
const fp1 = calculateObservationFingerprint({
|
|
120
|
+
receivingAccountId: 'acc_1',
|
|
121
|
+
amountPaise: 20000n,
|
|
122
|
+
bankReferenceNumber: 'UTR12345678',
|
|
123
|
+
observedAt: new Date('2026-09-08T10:00:00Z')
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const fp2 = calculateObservationFingerprint({
|
|
127
|
+
receivingAccountId: 'acc_1',
|
|
128
|
+
amountPaise: 20000n,
|
|
129
|
+
bankReferenceNumber: 'UTR12345678',
|
|
130
|
+
observedAt: new Date('2026-09-08T10:02:00Z') // even 2 mins later, same UTR = same fingerprint
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.equal(fp1, fp2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('generates cluster fingerprints within same 60-second window when no reference', () => {
|
|
137
|
+
const fp1 = calculateObservationFingerprint({
|
|
138
|
+
receivingAccountId: 'acc_1',
|
|
139
|
+
amountPaise: 20000n,
|
|
140
|
+
observedAt: new Date('2026-09-08T10:00:10Z')
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const fp2 = calculateObservationFingerprint({
|
|
144
|
+
receivingAccountId: 'acc_1',
|
|
145
|
+
amountPaise: 20000n,
|
|
146
|
+
observedAt: new Date('2026-09-08T10:00:45Z') // within same minute
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.equal(fp1, fp2);
|
|
150
|
+
|
|
151
|
+
const fpDiffTime = calculateObservationFingerprint({
|
|
152
|
+
receivingAccountId: 'acc_1',
|
|
153
|
+
amountPaise: 20000n,
|
|
154
|
+
observedAt: new Date('2026-09-08T10:05:00Z') // 5 minutes later
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.notEqual(fp1, fpDiffTime);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public and Internal API Contracts, DTO definitions, and Webhook Payloads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DefaultBranding = Object.freeze({
|
|
6
|
+
appName: 'ELPAY PG',
|
|
7
|
+
primaryColor: '#2563eb',
|
|
8
|
+
logoUrl: null
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Normalizes an email address to trimmed lowercase.
|
|
13
|
+
* @param {string} email
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeEmail(email) {
|
|
17
|
+
if (!email || typeof email !== 'string') return '';
|
|
18
|
+
return email.trim().toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates email format with standard requirements.
|
|
23
|
+
* @param {string} email
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isValidEmail(email) {
|
|
27
|
+
const norm = normalizeEmail(email);
|
|
28
|
+
if (!norm) return false;
|
|
29
|
+
return /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/.test(norm);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalizes a mobile number to canonical 10 digits (stripping prefixes and country codes).
|
|
34
|
+
* @param {string} mobile
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeMobile(mobile) {
|
|
38
|
+
if (!mobile) return '';
|
|
39
|
+
const cleaned = String(mobile).replace(/\D/g, '');
|
|
40
|
+
if (cleaned.length >= 10) {
|
|
41
|
+
return cleaned.slice(-10);
|
|
42
|
+
}
|
|
43
|
+
return cleaned;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validates canonical Indian 10-digit mobile number format.
|
|
48
|
+
* @param {string} mobile
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isValidMobile(mobile) {
|
|
52
|
+
const norm = normalizeMobile(mobile);
|
|
53
|
+
return /^[6-9]\d{9}$/.test(norm);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validates password strength (minimum 6 characters).
|
|
58
|
+
* @param {string} password
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isValidPassword(password) {
|
|
62
|
+
return typeof password === 'string' && password.length >= 6;
|
|
63
|
+
}
|
|
64
|
+
|