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,506 @@
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ safeEqual,
4
+ issueDeviceCredential,
5
+ verifyDeviceCredential,
6
+ createDeviceChallenge,
7
+ consumeDeviceChallenge
8
+ } from '../common/index.js';
9
+
10
+ export {
11
+ issueDeviceCredential,
12
+ verifyDeviceCredential,
13
+ createDeviceChallenge,
14
+ consumeDeviceChallenge
15
+ };
16
+
17
+ /**
18
+ * Normalizes privacy-conscious browser/device signals into a canonical dictionary.
19
+ * @param {Record<string, any>} signals
20
+ */
21
+ export function normalizeDeviceSignals(signals = {}) {
22
+ const ua = (signals.userAgent || '').toString().trim().replace(/\s+/g, ' ');
23
+ const platform = (signals.platform || '').toString().trim().toLowerCase();
24
+ const language = (signals.language || '').toString().trim().toLowerCase();
25
+ const timezone = (signals.timezone || '').toString().trim();
26
+
27
+ let screenSig = signals.screenSignature || '';
28
+ if (!screenSig && signals.screenWidth && signals.screenHeight) {
29
+ screenSig = `${signals.screenWidth}x${signals.screenHeight}x${signals.screenColorDepth || 24}`;
30
+ }
31
+
32
+ const hardwareConcurrency = Math.max(0, parseInt(signals.hardwareConcurrency, 10) || 0);
33
+ const deviceMemory = Math.max(0, parseFloat(signals.deviceMemory) || 0);
34
+ const webglRenderer = (signals.webglRenderer || signals.webgl || '').toString().trim().toLowerCase();
35
+ const canvasSignature = (signals.canvasSignature || '').toString().trim();
36
+
37
+ return {
38
+ canvasSignature,
39
+ deviceMemory,
40
+ hardwareConcurrency,
41
+ language,
42
+ platform,
43
+ screenSignature: screenSig,
44
+ timezone,
45
+ userAgent: ua,
46
+ webglRenderer
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Generates a stable SHA-256 hash of normalized browser/device signals.
52
+ * @param {Record<string, any>} signals
53
+ * @returns {string} SHA-256 hexadecimal hash
54
+ */
55
+ export function generateFingerprintHash(signals = {}) {
56
+ const normalized = normalizeDeviceSignals(signals);
57
+ // Canonical serialization: keys sorted deterministically
58
+ const canonicalString = JSON.stringify(normalized, Object.keys(normalized).sort());
59
+ return crypto.createHash('sha256').update(canonicalString).digest('hex');
60
+ }
61
+
62
+ /**
63
+ * Imports a public key from base64 SPKI, DER buffer, or JWK.
64
+ * @param {string|object} publicKeyInput
65
+ * @returns {crypto.KeyObject}
66
+ */
67
+ export function importDevicePublicKey(publicKeyInput) {
68
+ if (!publicKeyInput) {
69
+ throw new Error('Public key is required');
70
+ }
71
+
72
+ if (typeof publicKeyInput === 'object' && publicKeyInput.kty) {
73
+ return crypto.createPublicKey({
74
+ key: publicKeyInput,
75
+ format: 'jwk'
76
+ });
77
+ }
78
+
79
+ const str = String(publicKeyInput).trim();
80
+
81
+ // If PEM format
82
+ if (str.includes('BEGIN PUBLIC KEY')) {
83
+ return crypto.createPublicKey(str);
84
+ }
85
+
86
+ // If JSON JWK string
87
+ if (str.startsWith('{') && str.endsWith('}')) {
88
+ try {
89
+ const parsed = JSON.parse(str);
90
+ return crypto.createPublicKey({ key: parsed, format: 'jwk' });
91
+ } catch {}
92
+ }
93
+
94
+ // Otherwise assume base64 SPKI (DER)
95
+ const derBuffer = Buffer.from(str, 'base64');
96
+ return crypto.createPublicKey({
97
+ key: derBuffer,
98
+ format: 'der',
99
+ type: 'spki'
100
+ });
101
+ }
102
+
103
+ const DEFAULT_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
104
+ const DEFAULT_SIGNATURE_CLOCK_SKEW_MS = 60 * 1000;
105
+ const usedSignatureNonces = new Map();
106
+
107
+ function pruneSignatureNonces(now = Date.now()) {
108
+ if (usedSignatureNonces.size < 256) return;
109
+ for (const [k, exp] of usedSignatureNonces.entries()) {
110
+ if (exp <= now) usedSignatureNonces.delete(k);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Extracts and validates the anti-replay `{ nonce, timestamp }` metadata from a signed payload.
116
+ * Payloads must be a JSON string/object `{ nonce, timestamp, ... }` or `<nonce>:<timestamp>[:...]`.
117
+ * @param {string|Buffer|object} dataToVerify
118
+ * @param {{ maxAgeMs?: number, maxClockSkewMs?: number, expectedDeviceId?: string, expectedChallenge?: string }} [options]
119
+ * @returns {{ canonicalBuffer: Buffer, nonce: string, timestamp: number } | null}
120
+ */
121
+ export function parseSignedDevicePayload(dataToVerify, options = {}) {
122
+ if (dataToVerify === null || dataToVerify === undefined) return null;
123
+
124
+ const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : DEFAULT_SIGNATURE_MAX_AGE_MS;
125
+ const maxClockSkewMs = Number.isFinite(options.maxClockSkewMs) ? options.maxClockSkewMs : DEFAULT_SIGNATURE_CLOCK_SKEW_MS;
126
+
127
+ let rawString;
128
+ let parsedObj = null;
129
+
130
+ if (Buffer.isBuffer(dataToVerify)) {
131
+ rawString = dataToVerify.toString('utf8');
132
+ } else if (typeof dataToVerify === 'object') {
133
+ parsedObj = dataToVerify;
134
+ rawString = JSON.stringify(dataToVerify);
135
+ } else if (typeof dataToVerify === 'string') {
136
+ rawString = dataToVerify;
137
+ } else {
138
+ return null;
139
+ }
140
+
141
+ if (!parsedObj && rawString.trim().startsWith('{') && rawString.trim().endsWith('}')) {
142
+ try {
143
+ parsedObj = JSON.parse(rawString);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ let nonce = null;
150
+ let rawTs = null;
151
+
152
+ if (parsedObj && typeof parsedObj === 'object') {
153
+ nonce = typeof parsedObj.nonce === 'string' ? parsedObj.nonce.trim() : null;
154
+ rawTs = parsedObj.timestamp ?? parsedObj.ts ?? parsedObj.issuedAt;
155
+ if (options.expectedDeviceId && parsedObj.deviceId && parsedObj.deviceId !== options.expectedDeviceId) {
156
+ return null;
157
+ }
158
+ if (options.expectedChallenge && parsedObj.challenge !== options.expectedChallenge) {
159
+ return null;
160
+ }
161
+ } else {
162
+ // Support colon-separated `<nonce>:<timestamp>[:<challenge>...]`
163
+ const parts = rawString.split(':');
164
+ if (parts.length < 2) return null;
165
+ nonce = parts[0].trim();
166
+ rawTs = parts[1].trim();
167
+ if (options.expectedChallenge && !parts.slice(2).includes(options.expectedChallenge)) {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ if (!nonce || nonce.length < 8 || nonce.length > 256) {
173
+ return null;
174
+ }
175
+
176
+ let timestampMs = NaN;
177
+ if (typeof rawTs === 'number' && Number.isFinite(rawTs)) {
178
+ timestampMs = rawTs >= 1e9 && rawTs < 1e11 ? rawTs * 1000 : rawTs;
179
+ } else if (typeof rawTs === 'string' && rawTs.trim()) {
180
+ const num = Number(rawTs.trim());
181
+ if (Number.isFinite(num)) {
182
+ timestampMs = num >= 1e9 && num < 1e11 ? num * 1000 : num;
183
+ } else {
184
+ timestampMs = Date.parse(rawTs.trim());
185
+ }
186
+ }
187
+
188
+ if (!Number.isFinite(timestampMs) || timestampMs <= 0) {
189
+ return null;
190
+ }
191
+
192
+ const now = Date.now();
193
+ if (now - timestampMs > maxAgeMs) {
194
+ return null;
195
+ }
196
+ if (timestampMs - now > maxClockSkewMs) {
197
+ return null;
198
+ }
199
+
200
+ const canonicalBuffer = Buffer.isBuffer(dataToVerify)
201
+ ? dataToVerify
202
+ : Buffer.from(rawString, 'utf8');
203
+
204
+ return { canonicalBuffer, nonce, timestamp: timestampMs };
205
+ }
206
+
207
+ /**
208
+ * Verifies an ECDSA P-256 signature generated by browser Web Crypto.
209
+ * Requires a fresh timestamp and single-use nonce in the signed payload.
210
+ * @param {string|object} publicKey
211
+ * @param {string|Buffer|object} dataToVerify
212
+ * @param {string} signatureBase64
213
+ * @param {{ maxAgeMs?: number, maxClockSkewMs?: number, expectedDeviceId?: string, expectedChallenge?: string }} [options]
214
+ * @returns {boolean}
215
+ */
216
+ export function verifyDeviceSignature(publicKey, dataToVerify, signatureBase64, options = {}) {
217
+ try {
218
+ if (!publicKey || !signatureBase64) return false;
219
+
220
+ const parsed = parseSignedDevicePayload(dataToVerify, options);
221
+ if (!parsed) return false;
222
+
223
+ const keyObject = importDevicePublicKey(publicKey);
224
+ const dataBuffer = parsed.canonicalBuffer;
225
+ const sigStr = String(signatureBase64).trim();
226
+ if (!sigStr) return false;
227
+ const sigBuffer = Buffer.from(sigStr, 'base64');
228
+ if (sigBuffer.length === 0) return false;
229
+
230
+ const keyDer = keyObject.export({ format: 'der', type: 'spki' });
231
+ const keyFp = crypto.createHash('sha256').update(keyDer).digest('hex').slice(0, 32);
232
+ const nonceKey = `${keyFp}:${parsed.nonce}`;
233
+ const now = Date.now();
234
+ pruneSignatureNonces(now);
235
+
236
+ const existingExp = usedSignatureNonces.get(nonceKey);
237
+ if (existingExp && existingExp > now) {
238
+ return false;
239
+ }
240
+
241
+ let verified = false;
242
+
243
+ // Try ieee-p1363 (standard Web Crypto ECDSA output: r || s, 64 bytes)
244
+ try {
245
+ verified = crypto.verify(
246
+ 'SHA256',
247
+ dataBuffer,
248
+ { key: keyObject, dsaEncoding: 'ieee-p1363' },
249
+ sigBuffer
250
+ );
251
+ } catch {}
252
+
253
+ // Fallback to standard DER encoding
254
+ if (!verified) {
255
+ try {
256
+ verified = crypto.verify(
257
+ 'SHA256',
258
+ dataBuffer,
259
+ keyObject,
260
+ sigBuffer
261
+ );
262
+ } catch {}
263
+ }
264
+
265
+ if (!verified) return false;
266
+
267
+ const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : DEFAULT_SIGNATURE_MAX_AGE_MS;
268
+ usedSignatureNonces.set(nonceKey, now + maxAgeMs);
269
+ return true;
270
+ } catch (err) {
271
+ console.warn('[DeviceSecurity] Signature verification error:', err.message);
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Verifies that a device exists, is ACTIVE, is not an adhoc placeholder, and that the
278
+ * caller presented a valid cryptographic credential (token or ECDSA signature).
279
+ * @param {{
280
+ * deviceId: string,
281
+ * deviceToken?: string|null,
282
+ * deviceSignature?: string|null,
283
+ * signaturePayload?: string|object|null,
284
+ * deviceRepo: any
285
+ * }} params
286
+ * @returns {{ valid: boolean, error?: string, message?: string, device?: any }}
287
+ */
288
+ export function verifyRegisteredDeviceCredentials({
289
+ deviceId,
290
+ deviceToken,
291
+ deviceSignature,
292
+ signaturePayload,
293
+ deviceRepo
294
+ }) {
295
+ if (!deviceId || typeof deviceId !== 'string') {
296
+ return {
297
+ valid: false,
298
+ error: 'DEVICE_ID_REQUIRED',
299
+ message: 'A registered deviceId is required.'
300
+ };
301
+ }
302
+ if (!deviceRepo) {
303
+ return {
304
+ valid: false,
305
+ error: 'DEVICE_REPO_UNAVAILABLE',
306
+ message: 'Device repository is required for device verification.'
307
+ };
308
+ }
309
+
310
+ const dev = deviceRepo.findById(deviceId);
311
+ if (!dev || dev.status !== 'ACTIVE' || (typeof dev.publicKey === 'string' && dev.publicKey.startsWith('adhoc_'))) {
312
+ return {
313
+ valid: false,
314
+ error: 'DEVICE_REVOKED',
315
+ message: 'The device is unregistered, revoked, or invalid.'
316
+ };
317
+ }
318
+
319
+ const hasToken = typeof deviceToken === 'string' && deviceToken.trim().length > 0;
320
+ const hasSig = typeof deviceSignature === 'string' && deviceSignature.trim().length > 0;
321
+ const hasPayload = signaturePayload !== undefined && signaturePayload !== null && String(signaturePayload).trim().length > 0;
322
+
323
+ if (!hasToken && !hasSig) {
324
+ return {
325
+ valid: false,
326
+ error: 'DEVICE_CREDENTIALS_REQUIRED',
327
+ message: 'Device authentication credential (token or cryptographic signature) is required.'
328
+ };
329
+ }
330
+
331
+ if ((hasSig && !hasPayload) || (!hasSig && hasPayload)) {
332
+ return {
333
+ valid: false,
334
+ error: 'DEVICE_SIGNATURE_INVALID',
335
+ message: 'Both device signature and signed payload are required for signature verification.'
336
+ };
337
+ }
338
+
339
+ if (hasToken) {
340
+ let tokenOk = false;
341
+ if (typeof deviceRepo.verifyToken === 'function') {
342
+ tokenOk = deviceRepo.verifyToken(deviceId, deviceToken.trim());
343
+ } else if (dev.deviceToken) {
344
+ tokenOk = safeEqual(dev.deviceToken, deviceToken.trim()) && verifyDeviceCredential(deviceId, deviceToken.trim());
345
+ } else {
346
+ tokenOk = verifyDeviceCredential(deviceId, deviceToken.trim());
347
+ }
348
+ if (!tokenOk) {
349
+ return {
350
+ valid: false,
351
+ error: 'DEVICE_TOKEN_INVALID',
352
+ message: 'Device token failed verification.'
353
+ };
354
+ }
355
+ }
356
+
357
+ if (hasSig) {
358
+ const sigOk = verifyDeviceSignature(dev.publicKey, signaturePayload, deviceSignature, {
359
+ expectedDeviceId: deviceId
360
+ });
361
+ if (!sigOk) {
362
+ return {
363
+ valid: false,
364
+ error: 'DEVICE_SIGNATURE_INVALID',
365
+ message: 'Device cryptographic signature failed verification.'
366
+ };
367
+ }
368
+ }
369
+
370
+ return { valid: true, device: dev };
371
+ }
372
+
373
+ /**
374
+ * Validates whether the requesting device matches the payment session's bound device.
375
+ * @param {{
376
+ * session: any,
377
+ * requestingDeviceId?: string|null,
378
+ * deviceToken?: string|null,
379
+ * deviceSignature?: string|null,
380
+ * signaturePayload?: string|null,
381
+ * deviceRepo?: any
382
+ * }} options
383
+ * @returns {{
384
+ * allowed: boolean,
385
+ * shouldBind?: boolean,
386
+ * error?: string,
387
+ * session_id?: string,
388
+ * action?: string,
389
+ * message?: string,
390
+ * boundDeviceId?: string|null,
391
+ * requestingDeviceId?: string|null
392
+ * }}
393
+ */
394
+ export function validateSessionDeviceAccess(options) {
395
+ const { session, requestingDeviceId, deviceToken, deviceSignature, signaturePayload, deviceRepo } = options || {};
396
+
397
+ if (!session) {
398
+ return {
399
+ allowed: false,
400
+ error: 'SESSION_NOT_FOUND',
401
+ message: 'Payment session not found'
402
+ };
403
+ }
404
+
405
+ const boundDeviceId = session.deviceId;
406
+
407
+ // If session is not yet bound to any device:
408
+ // Only allow binding if the caller presents a registered, cryptographically verified device.
409
+ if (!boundDeviceId) {
410
+ if (!requestingDeviceId) {
411
+ return {
412
+ allowed: false,
413
+ error: 'DEVICE_ID_REQUIRED',
414
+ session_id: session.id,
415
+ action: 'REGISTER_DEVICE',
416
+ message: 'A registered device identity is required to access or bind this checkout session.',
417
+ boundDeviceId: null,
418
+ requestingDeviceId: null
419
+ };
420
+ }
421
+
422
+ const credCheck = verifyRegisteredDeviceCredentials({
423
+ deviceId: requestingDeviceId,
424
+ deviceToken,
425
+ deviceSignature,
426
+ signaturePayload,
427
+ deviceRepo
428
+ });
429
+
430
+ if (!credCheck.valid) {
431
+ return {
432
+ allowed: false,
433
+ error: credCheck.error === 'DEVICE_SIGNATURE_INVALID' ? 'DEVICE_SIGNATURE_INVALID' : 'DUPLICATE_SESSION_DETECTED',
434
+ session_id: session.id,
435
+ action: 'END_SESSION_AND_CREATE_NEW',
436
+ message: credCheck.message || 'Valid registered device credentials are required.',
437
+ boundDeviceId: null,
438
+ requestingDeviceId
439
+ };
440
+ }
441
+
442
+ return {
443
+ allowed: true,
444
+ shouldBind: true,
445
+ requestingDeviceId
446
+ };
447
+ }
448
+
449
+ // If requesting device ID is missing or does not match bound device
450
+ if (!requestingDeviceId || requestingDeviceId !== boundDeviceId) {
451
+ return {
452
+ allowed: false,
453
+ error: 'DUPLICATE_SESSION_DETECTED',
454
+ session_id: session.id,
455
+ action: 'END_SESSION_AND_CREATE_NEW',
456
+ message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.',
457
+ boundDeviceId,
458
+ requestingDeviceId: requestingDeviceId || null
459
+ };
460
+ }
461
+
462
+ // Verify the bound device is registered, active, and that the caller supplied a valid token or signature
463
+ const credCheck = verifyRegisteredDeviceCredentials({
464
+ deviceId: boundDeviceId,
465
+ deviceToken,
466
+ deviceSignature,
467
+ signaturePayload,
468
+ deviceRepo
469
+ });
470
+
471
+ if (!credCheck.valid) {
472
+ if (credCheck.error === 'DEVICE_REVOKED') {
473
+ return {
474
+ allowed: false,
475
+ error: 'DEVICE_REVOKED',
476
+ session_id: session.id,
477
+ action: 'END_SESSION_AND_CREATE_NEW',
478
+ message: 'The device registered for this payment session has been revoked or is invalid.'
479
+ };
480
+ }
481
+ if (credCheck.error === 'DEVICE_SIGNATURE_INVALID') {
482
+ return {
483
+ allowed: false,
484
+ error: 'DEVICE_SIGNATURE_INVALID',
485
+ session_id: session.id,
486
+ action: 'END_SESSION_AND_CREATE_NEW',
487
+ message: 'Device cryptographic signature failed verification.'
488
+ };
489
+ }
490
+ return {
491
+ allowed: false,
492
+ error: 'DUPLICATE_SESSION_DETECTED',
493
+ session_id: session.id,
494
+ action: 'END_SESSION_AND_CREATE_NEW',
495
+ message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.',
496
+ boundDeviceId,
497
+ requestingDeviceId
498
+ };
499
+ }
500
+
501
+ return {
502
+ allowed: true,
503
+ boundDeviceId,
504
+ requestingDeviceId
505
+ };
506
+ }
@@ -0,0 +1,13 @@
1
+ export * from './state-machine.js';
2
+ export * from './normalizer.js';
3
+ export * from './attribution-engine.js';
4
+ export * from './queue-engine.js';
5
+ export * from './timing-engine.js';
6
+ export * from './recovery-engine.js';
7
+ export * from './webhook-engine.js';
8
+ export * from './bank-sms-parser.js';
9
+ export * from './bank-email-parser.js';
10
+ export * from './qr.js';
11
+ export * from './verification-pipeline.js';
12
+ export * from './device-security.js';
13
+
@@ -0,0 +1,165 @@
1
+ import { ObserverSource, ObservationAttributionStatus, toPaise, calculateObservationFingerprint } from '../common/index.js';
2
+
3
+ /**
4
+ * Intelligent Monetary Amount Extractor.
5
+ * Extracts amounts with prefix (Rs. 20.00, INR 500), suffix (20.00 Rs),
6
+ * proximity words ("20.00 received", "received 20.00", "credited 20"),
7
+ * or clean standalone decimal amounts ("20.00").
8
+ * @param {string} text
9
+ * @returns {bigint | null}
10
+ */
11
+ export function extractIntelligentPaise(text) {
12
+ if (!text || typeof text !== 'string') return null;
13
+
14
+ // Pattern 1: Standard currency prefix (e.g. Rs. 20.00, Rs 200, INR 500.00, ₹1,200.50, ₹20)
15
+ const prefixMatch = text.match(/(?:rs\.?|inr|₹)\s*([\d,]+(?:\.\d{1,2})?)/i);
16
+ if (prefixMatch && prefixMatch[1]) {
17
+ const clean = prefixMatch[1].replace(/,/g, '');
18
+ try { return toPaise(clean); } catch {}
19
+ }
20
+
21
+ // Pattern 2: Standard currency suffix (e.g. 20.00 Rs, 20.00 INR, 20rs, 500 inr)
22
+ const suffixMatch = text.match(/([\d,]+(?:\.\d{1,2})?)\s*(?:rs\.?|inr|₹)/i);
23
+ if (suffixMatch && suffixMatch[1]) {
24
+ const clean = suffixMatch[1].replace(/,/g, '');
25
+ try { return toPaise(clean); } catch {}
26
+ }
27
+
28
+ // Pattern 3: Number in proximity to credit keywords (e.g. "20.00 received", "received 20.00", "credited 20", "20.00 credited")
29
+ const creditProximityMatch = text.match(/(?:credited|received|deposit|payment|paid|transferred|upi|cr)[\s\w:]*?([\d,]+(?:\.\d{1,2})?)|([\d,]+(?:\.\d{1,2})?)[\s\w:]*?(?:credited|received|deposit|payment|paid|transferred|upi|cr)/i);
30
+ if (creditProximityMatch) {
31
+ const rawVal = creditProximityMatch[1] || creditProximityMatch[2];
32
+ if (rawVal) {
33
+ const clean = rawVal.replace(/,/g, '');
34
+ if (/^\d+(\.\d{1,2})?$/.test(clean) && Number(clean) < 10000000 && Number(clean) > 0) {
35
+ try { return toPaise(clean); } catch {}
36
+ }
37
+ }
38
+ }
39
+
40
+ // Pattern 4: Standalone decimal currency number (e.g. 20.00, 150.50, 449.00)
41
+ const standaloneDecimal = text.match(/\b(\d{1,6}\.\d{2})\b/);
42
+ if (standaloneDecimal && standaloneDecimal[1]) {
43
+ try { return toPaise(standaloneDecimal[1]); } catch {}
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Regex for extracting bank UTR / Reference numbers.
51
+ * Matches: UTR: 123456789012, Ref 123456789012, Txn: ABC123XYZ, RRN: 987654
52
+ */
53
+ const REFERENCE_REGEX = /(?:utr|ref(?:\s*no\.?)?|txn(?:\s*(?:id|#))?|transaction\s*id|rrn|reference(?:\s*no\.?)?|upi(?:\s*(?:ref|reference|txn))?)[:\s#]+([a-zA-Z0-9\-_]{6,35})/i;
54
+
55
+ /**
56
+ * Regex for extracting sender VPA if present in notification text.
57
+ * Matches: from user@upi, from 9876543210@paytm, from Rahul (UPI ID: rahul@fam), etc.
58
+ */
59
+ const SENDER_VPA_REGEX = /(?:from|by|vpa|upi\s*id)[\s:]+(?:[a-zA-Z\s]+[\(\[])?([a-zA-Z0-9.\-_]{2,100}@[a-zA-Z0-9.\-_]{2,64})/i;
60
+
61
+ export class ObservationNormalizer {
62
+ /**
63
+ * Normalizes raw incoming data from any observation source into a canonical observation record.
64
+ * @param {{
65
+ * source: string,
66
+ * receivingAccountId: string,
67
+ * rawPayload: Record<string, any>,
68
+ * amountPaise?: bigint | number | string,
69
+ * amount?: string | number,
70
+ * observedAt?: Date | string,
71
+ * senderVpa?: string,
72
+ * bankReferenceNumber?: string
73
+ * }} input
74
+ * @returns {{
75
+ * receivingAccountId: string,
76
+ * source: string,
77
+ * amountPaise: bigint,
78
+ * observedAt: Date,
79
+ * senderVpa?: string,
80
+ * bankReferenceNumber?: string,
81
+ * fingerprintHash: string,
82
+ * attributionStatus: string,
83
+ * rawPayload: Record<string, any>
84
+ * }}
85
+ */
86
+ static normalize(input) {
87
+ if (!input.receivingAccountId) {
88
+ throw new Error('Observation normalization failed: receivingAccountId is required.');
89
+ }
90
+ if (!input.source || !Object.values(ObserverSource).includes(input.source)) {
91
+ throw new Error(`Observation normalization failed: invalid or unknown source '${input.source}'.`);
92
+ }
93
+
94
+ const payload = input.rawPayload || {};
95
+ const textContent = (payload.text || payload.body || payload.notificationText || payload.snippet || '').toString();
96
+
97
+ // 1. Resolve Amount using priority resolution: explicit paise -> explicit amount -> intelligent multi-pattern extraction
98
+ let finalAmountPaise;
99
+ if (input.amountPaise !== undefined && input.amountPaise !== null && input.amountPaise !== '') {
100
+ finalAmountPaise = BigInt(input.amountPaise);
101
+ } else if (input.amount !== undefined && input.amount !== null && input.amount !== '') {
102
+ finalAmountPaise = toPaise(input.amount);
103
+ } else {
104
+ const extracted = extractIntelligentPaise(textContent);
105
+ if (!extracted) {
106
+ throw new Error(`Observation normalization failed: could not extract valid monetary amount from payload text: "${textContent}"`);
107
+ }
108
+ finalAmountPaise = extracted;
109
+ }
110
+
111
+ // 2. Resolve Observed Timestamp
112
+ let observedAtDate;
113
+ if (input.observedAt) {
114
+ observedAtDate = input.observedAt instanceof Date ? input.observedAt : new Date(input.observedAt);
115
+ } else if (payload.timestamp || payload.postTime || payload.date) {
116
+ const rawTs = payload.timestamp || payload.postTime || payload.date;
117
+ observedAtDate = new Date(typeof rawTs === 'number' ? rawTs : rawTs.toString());
118
+ } else {
119
+ observedAtDate = new Date();
120
+ }
121
+
122
+ if (isNaN(observedAtDate.getTime())) {
123
+ observedAtDate = new Date();
124
+ }
125
+
126
+ // 3. Resolve Reference Number / UTR
127
+ let bankRef = input.bankReferenceNumber;
128
+ if (!bankRef && textContent) {
129
+ const refMatch = textContent.match(REFERENCE_REGEX);
130
+ if (refMatch && refMatch[1]) {
131
+ bankRef = refMatch[1].trim();
132
+ }
133
+ }
134
+
135
+ // 4. Resolve Sender VPA
136
+ let senderVpa = input.senderVpa;
137
+ if (!senderVpa && textContent) {
138
+ const vpaMatch = textContent.match(SENDER_VPA_REGEX);
139
+ if (vpaMatch && vpaMatch[1]) {
140
+ senderVpa = vpaMatch[1].trim().replace(/[.,;]+$/, '');
141
+ }
142
+ }
143
+
144
+ // 5. Compute Deterministic Fingerprint
145
+ const fingerprintHash = calculateObservationFingerprint({
146
+ receivingAccountId: input.receivingAccountId,
147
+ amountPaise: finalAmountPaise,
148
+ source: input.source,
149
+ bankReferenceNumber: bankRef,
150
+ observedAt: observedAtDate
151
+ });
152
+
153
+ return {
154
+ receivingAccountId: input.receivingAccountId,
155
+ source: input.source,
156
+ amountPaise: finalAmountPaise,
157
+ observedAt: observedAtDate,
158
+ senderVpa: senderVpa || undefined,
159
+ bankReferenceNumber: bankRef || undefined,
160
+ fingerprintHash,
161
+ attributionStatus: ObservationAttributionStatus.UNATTRIBUTED,
162
+ rawPayload: payload
163
+ };
164
+ }
165
+ }