elpaypg 0.0.0-stage → 2.0.1

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 +2424 -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,56 @@
1
+ /**
2
+ * Financial utilities enforcing integer minor units (paise).
3
+ * Floating-point representation is forbidden in financial calculations.
4
+ * 1 INR = 100 paise.
5
+ */
6
+
7
+ /**
8
+ * Converts a rupee amount (string or number) into integer paise (bigint).
9
+ * Throws an error if invalid, negative, zero, or exceeds 2 decimal places.
10
+ */
11
+ export function toPaise(rupees: string | number): bigint {
12
+ if (typeof rupees === 'number') {
13
+ if (!Number.isFinite(rupees) || isNaN(rupees)) {
14
+ throw new Error(`Invalid monetary amount: ${rupees}`);
15
+ }
16
+ rupees = rupees.toString();
17
+ }
18
+
19
+ const clean = rupees.trim();
20
+ if (!/^\d+(\.\d{1,2})?$/.test(clean)) {
21
+ throw new Error(`Invalid INR rupee format: "${rupees}". Must be non-negative with max 2 decimals.`);
22
+ }
23
+
24
+ const parts = clean.split('.');
25
+ const whole = BigInt(parts[0] || '0');
26
+ const fractionStr = parts[1] || '';
27
+ const fraction = BigInt(fractionStr.padEnd(2, '0'));
28
+
29
+ const totalPaise = whole * 100n + fraction;
30
+ if (totalPaise <= 0n) {
31
+ throw new Error(`Monetary amount must be strictly greater than 0: ${rupees}`);
32
+ }
33
+
34
+ return totalPaise;
35
+ }
36
+
37
+ /**
38
+ * Formats integer paise into a standard decimal string ("200.00").
39
+ */
40
+ export function toRupeesString(paise: bigint | number): string {
41
+ const p = typeof paise === 'number' ? BigInt(Math.floor(paise)) : paise;
42
+ if (p < 0n) {
43
+ throw new Error(`Negative paise value not permitted: ${p}`);
44
+ }
45
+
46
+ const whole = p / 100n;
47
+ const fraction = p % 100n;
48
+ return `${whole}.${fraction.toString().padStart(2, '0')}`;
49
+ }
50
+
51
+ /**
52
+ * Formats integer paise into a localized INR display string ("₹200.00").
53
+ */
54
+ export function formatINR(paise: bigint | number): string {
55
+ return `₹${toRupeesString(paise)}`;
56
+ }
@@ -0,0 +1,27 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Calculates a deterministic fingerprint for an observation to prevent duplicate processing.
5
+ * If bankReferenceNumber exists, it hashes receivingAccount + amount + referenceNumber.
6
+ * Otherwise, it hashes receivingAccount + amount + 60-second time bucket.
7
+ * @param {{
8
+ * receivingAccountId: string,
9
+ * amountPaise: bigint,
10
+ * bankReferenceNumber?: string,
11
+ * observedAt: Date
12
+ * }} input
13
+ * @returns {string}
14
+ */
15
+ export function calculateObservationFingerprint(input) {
16
+ let entropy;
17
+ if (input.bankReferenceNumber && input.bankReferenceNumber.trim().length > 0) {
18
+ entropy = `REF:${input.bankReferenceNumber.trim().toUpperCase()}`;
19
+ } else {
20
+ // 60-second temporal cluster bucket partitioned by channel source
21
+ const bucket = Math.floor(input.observedAt.getTime() / 60000);
22
+ entropy = `TIME_BUCKET:${bucket}:${input.source || ''}`;
23
+ }
24
+
25
+ const raw = `${input.receivingAccountId}:${input.amountPaise.toString()}:${entropy}`;
26
+ return crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
27
+ }
@@ -0,0 +1,52 @@
1
+ import crypto from 'node:crypto';
2
+ import { ObserverSource, ObservationAttributionStatus } from './enums.js';
3
+
4
+ export interface CanonicalObservation {
5
+ id: string;
6
+ receivingAccountId: string;
7
+ source: ObserverSource;
8
+ amountPaise: bigint;
9
+ observedAt: Date;
10
+ ingestedAt: Date;
11
+ senderVpa?: string;
12
+ bankReferenceNumber?: string;
13
+ fingerprintHash: string;
14
+ attributionStatus: ObservationAttributionStatus;
15
+ attributedPaymentId?: string;
16
+ rawPayload: Record<string, unknown>;
17
+ }
18
+
19
+ export interface IngestObservationInput {
20
+ receivingAccountId: string;
21
+ source: ObserverSource;
22
+ amountPaise: bigint;
23
+ observedAt: Date;
24
+ senderVpa?: string;
25
+ bankReferenceNumber?: string;
26
+ rawPayload?: Record<string, unknown>;
27
+ }
28
+
29
+ /**
30
+ * Calculates a deterministic fingerprint for an observation to prevent duplicate processing.
31
+ * If bankReferenceNumber exists, it hashes receivingAccount + amount + referenceNumber.
32
+ * Otherwise, it hashes receivingAccount + amount + 60-second time bucket.
33
+ */
34
+ export function calculateObservationFingerprint(input: {
35
+ receivingAccountId: string;
36
+ amountPaise: bigint;
37
+ source?: string;
38
+ bankReferenceNumber?: string;
39
+ observedAt: Date;
40
+ }): string {
41
+ let entropy: string;
42
+ if (input.bankReferenceNumber && input.bankReferenceNumber.trim().length > 0) {
43
+ entropy = `REF:${input.bankReferenceNumber.trim().toUpperCase()}`;
44
+ } else {
45
+ // 60-second temporal cluster bucket partitioned by channel source
46
+ const bucket = Math.floor(input.observedAt.getTime() / 60000);
47
+ entropy = `TIME_BUCKET:${bucket}:${input.source || ''}`;
48
+ }
49
+
50
+ const raw = `${input.receivingAccountId}:${input.amountPaise.toString()}:${entropy}`;
51
+ return crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
52
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Canonical Cloudflare Tunnel and Runtime URL Resolution Engine.
3
+ * Single source of truth for runtime mode, derived hostnames, and service mappings.
4
+ */
5
+
6
+ export const SERVICE_PORTS = Object.freeze({
7
+ CHECKOUT: 7000,
8
+ API: 6100,
9
+ ADMIN: 6001,
10
+ GMAIL: 6003,
11
+ STORE: 5000
12
+ });
13
+
14
+ /**
15
+ * Derives public hostnames and Cloudflare published application mappings.
16
+ * @param {{ domain?: string, prefix?: string }} config
17
+ * @returns {Array<{
18
+ * role: string,
19
+ * name: string,
20
+ * publicHostname: string,
21
+ * publicUrl: string,
22
+ * serviceUrl: string,
23
+ * port: number,
24
+ * purpose: string,
25
+ * optional: boolean
26
+ * }>}
27
+ */
28
+ export function deriveCloudflareRoutes(config = {}) {
29
+ const rawDomain = (config.domain || config.domainTld || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
30
+ const rawPrefix = (config.prefix || config.domainPrefix || 'pay').trim().toLowerCase().replace(/[^a-z0-9-_]/g, '') || 'pay';
31
+
32
+ if (!rawDomain) {
33
+ return [];
34
+ }
35
+
36
+ return [
37
+ {
38
+ role: 'checkout',
39
+ name: 'Checkout',
40
+ publicHostname: `${rawPrefix}.${rawDomain}`,
41
+ publicUrl: `https://${rawPrefix}.${rawDomain}`,
42
+ serviceUrl: `http://localhost:${SERVICE_PORTS.CHECKOUT}`,
43
+ port: SERVICE_PORTS.CHECKOUT,
44
+ purpose: 'Customer checkout / checkout portal',
45
+ optional: false
46
+ },
47
+ {
48
+ role: 'api',
49
+ name: 'Payment API',
50
+ publicHostname: `${rawPrefix}-api.${rawDomain}`,
51
+ publicUrl: `https://${rawPrefix}-api.${rawDomain}`,
52
+ serviceUrl: `http://localhost:${SERVICE_PORTS.API}`,
53
+ port: SERVICE_PORTS.API,
54
+ purpose: 'Payment API / merchant API / SSE / observer ingestion',
55
+ optional: false
56
+ },
57
+ {
58
+ role: 'admin',
59
+ name: 'Merchant Admin',
60
+ publicHostname: `${rawPrefix}-admin.${rawDomain}`,
61
+ publicUrl: `https://${rawPrefix}-admin.${rawDomain}`,
62
+ serviceUrl: `http://localhost:${SERVICE_PORTS.ADMIN}`,
63
+ port: SERVICE_PORTS.ADMIN,
64
+ purpose: 'Merchant/Admin panel',
65
+ optional: false
66
+ },
67
+ {
68
+ role: 'gmail',
69
+ name: 'Gmail OAuth / Gmail Observer',
70
+ publicHostname: `${rawPrefix}-gmail.${rawDomain}`,
71
+ publicUrl: `https://${rawPrefix}-gmail.${rawDomain}`,
72
+ serviceUrl: `http://localhost:${SERVICE_PORTS.GMAIL}`,
73
+ port: SERVICE_PORTS.GMAIL,
74
+ purpose: 'Gmail OAuth callback and Gmail observation service',
75
+ optional: false
76
+ },
77
+ {
78
+ role: 'store',
79
+ name: 'Demo Store',
80
+ publicHostname: `${rawPrefix}-store.${rawDomain}`,
81
+ publicUrl: `https://${rawPrefix}-store.${rawDomain}`,
82
+ serviceUrl: `http://localhost:${SERVICE_PORTS.STORE}`,
83
+ port: SERVICE_PORTS.STORE,
84
+ purpose: 'Reference/demo store',
85
+ optional: true
86
+ }
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Resolves authoritative runtime URLs for all system components.
92
+ * @param {{
93
+ * envMode?: string,
94
+ * domain?: string,
95
+ * domainTld?: string,
96
+ * prefix?: string,
97
+ * domainPrefix?: string
98
+ * }} options
99
+ */
100
+ export function resolveRuntimeUrls(options = {}) {
101
+ let mode = options.envMode;
102
+ if (!mode) {
103
+ if (process.env.ELPAY_MODE) {
104
+ mode = process.env.ELPAY_MODE;
105
+ } else if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
106
+ mode = 'localhost';
107
+ } else if (process.env.CLOUDFLARE_DOMAIN) {
108
+ mode = 'production';
109
+ } else {
110
+ mode = 'localhost';
111
+ }
112
+ }
113
+ mode = String(mode).toLowerCase();
114
+ const domain = (options.domain || options.domainTld || process.env.CLOUDFLARE_DOMAIN || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
115
+ const prefix = (options.prefix || options.domainPrefix || process.env.DEPLOYMENT_PREFIX || 'pay').trim().toLowerCase().replace(/[^a-z0-9-_]/g, '') || 'pay';
116
+
117
+ if (mode === 'production') {
118
+ if (!domain) {
119
+ if ((process.env.NODE_ENV === 'test' || process.env.ELPAY_MODE === 'localhost') && options.envMode !== 'production') {
120
+ mode = 'localhost';
121
+ } else {
122
+ throw new Error('Production Configuration Incomplete: Cloudflare Domain is required when running in PRODUCTION mode.');
123
+ }
124
+ }
125
+ }
126
+
127
+ if (mode === 'production') {
128
+
129
+ const routes = deriveCloudflareRoutes({ domain, prefix });
130
+ const getRoute = (role) => routes.find(r => r.role === role);
131
+
132
+ return {
133
+ mode: 'production',
134
+ isProduction: true,
135
+ domain,
136
+ prefix,
137
+ routes,
138
+ checkoutUrl: getRoute('checkout')?.publicUrl || `https://${prefix}.${domain}`,
139
+ checkoutHostname: getRoute('checkout')?.publicHostname || `${prefix}.${domain}`,
140
+ apiUrl: getRoute('api')?.publicUrl || `https://${prefix}-api.${domain}`,
141
+ apiHostname: getRoute('api')?.publicHostname || `${prefix}-api.${domain}`,
142
+ adminUrl: getRoute('admin')?.publicUrl || `https://${prefix}-admin.${domain}`,
143
+ adminHostname: getRoute('admin')?.publicHostname || `${prefix}-admin.${domain}`,
144
+ gmailUrl: getRoute('gmail')?.publicUrl || `https://${prefix}-gmail.${domain}`,
145
+ gmailHostname: getRoute('gmail')?.publicHostname || `${prefix}-gmail.${domain}`,
146
+ storeUrl: getRoute('store')?.publicUrl || `https://${prefix}-store.${domain}`,
147
+ storeHostname: getRoute('store')?.publicHostname || `${prefix}-store.${domain}`
148
+ };
149
+ }
150
+
151
+ // Localhost / Development Mode
152
+ return {
153
+ mode: 'localhost',
154
+ isProduction: false,
155
+ domain: domain || 'localhost',
156
+ prefix: prefix || 'pay',
157
+ routes: [
158
+ { role: 'checkout', name: 'Checkout', publicHostname: 'localhost:7000', publicUrl: 'http://localhost:7000', serviceUrl: 'http://localhost:7000', port: 7000, purpose: 'Customer checkout / checkout portal', optional: false },
159
+ { role: 'api', name: 'Payment API', publicHostname: 'localhost:6100', publicUrl: 'http://localhost:6100', serviceUrl: 'http://localhost:6100', port: 6100, purpose: 'Payment API / merchant API / SSE / observer ingestion', optional: false },
160
+ { role: 'admin', name: 'Merchant Admin', publicHostname: 'localhost:6001', publicUrl: 'http://localhost:6001', serviceUrl: 'http://localhost:6001', port: 6001, purpose: 'Merchant/Admin panel', optional: false },
161
+ { role: 'gmail', name: 'Gmail OAuth / Gmail Observer', publicHostname: 'localhost:6003', publicUrl: 'http://localhost:6003', serviceUrl: 'http://localhost:6003', port: 6003, purpose: 'Gmail OAuth callback and Gmail observation service', optional: false },
162
+ { role: 'store', name: 'Demo Store', publicHostname: 'localhost:5000', publicUrl: 'http://localhost:5000', serviceUrl: 'http://localhost:5000', port: 5000, purpose: 'Reference/demo store', optional: true }
163
+ ],
164
+ checkoutUrl: 'http://localhost:7000',
165
+ checkoutHostname: 'localhost:7000',
166
+ apiUrl: 'http://localhost:6100',
167
+ apiHostname: 'localhost:6100',
168
+ adminUrl: 'http://localhost:6001',
169
+ adminHostname: 'localhost:6001',
170
+ gmailUrl: 'http://localhost:6003',
171
+ gmailHostname: 'localhost:6003',
172
+ storeUrl: 'http://localhost:5000',
173
+ storeHostname: 'localhost:5000'
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Generates structured SMS Gateway QR pairing payload according to Part C1 & C2.
179
+ */
180
+ export function createPairingPayload(options = {}) {
181
+ const {
182
+ serverUrl,
183
+ sessionId = `pair_sess_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
184
+ token = `tok_${Math.random().toString(36).substring(2, 12)}`,
185
+ merchantId = '00000000-0000-0000-0000-000000000001',
186
+ ttlMs = 10 * 60 * 1000 // 10 minutes default
187
+ } = options;
188
+
189
+ return {
190
+ version: 'v1',
191
+ type: 'ELPAY_SMS_GATEWAY_PAIR',
192
+ serverUrl: serverUrl || 'http://localhost:6100',
193
+ sessionId,
194
+ token,
195
+ merchantId,
196
+ expiresAt: Date.now() + ttlMs,
197
+ issuedAt: Date.now()
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Validates pairing payload according to Part C5.
203
+ */
204
+ export function validatePairingPayload(payload) {
205
+ if (!payload || typeof payload !== 'object') {
206
+ return { valid: false, error: 'INVALID ELPAY PAIRING QR' };
207
+ }
208
+ if (payload.type !== 'ELPAY_SMS_GATEWAY_PAIR') {
209
+ return { valid: false, error: 'INVALID ELPAY PAIRING QR' };
210
+ }
211
+ if (payload.version !== 'v1') {
212
+ return { valid: false, error: 'INVALID ELPAY PAIRING QR VERSION' };
213
+ }
214
+ if (!payload.serverUrl || !payload.token || !payload.sessionId) {
215
+ return { valid: false, error: 'MALFORMED ELPAY PAIRING QR: Missing required fields' };
216
+ }
217
+ if (!payload.serverUrl.startsWith('http://') && !payload.serverUrl.startsWith('https://')) {
218
+ return { valid: false, error: 'INVALID SERVER URL PROTOCOL' };
219
+ }
220
+ if (payload.expiresAt && Date.now() > payload.expiresAt) {
221
+ return { valid: false, error: 'PAIRING QR EXPIRED' };
222
+ }
223
+ return { valid: true };
224
+ }
225
+
226
+ /**
227
+ * Resolves active runtime pairing API URL.
228
+ */
229
+ export function resolvePairingUrl(options = {}) {
230
+ const urls = resolveRuntimeUrls(options);
231
+ return urls.apiUrl || 'http://localhost:6100';
232
+ }
233
+
234
+ /**
235
+ * OAuth redirect URI for the Gmail observer. `GMAIL_REDIRECT_URI` wins; otherwise it is derived
236
+ * from the configured domain, falling back to the local observer port.
237
+ * @param {object} [branding]
238
+ */
239
+ export function resolveGmailRedirectUri(branding = {}) {
240
+ if (process.env.GMAIL_REDIRECT_URI) return process.env.GMAIL_REDIRECT_URI;
241
+ try {
242
+ return `${resolveRuntimeUrls(branding || {}).gmailUrl}/oauth/callback`;
243
+ } catch {
244
+ return `http://localhost:${process.env.GMAIL_OBSERVER_PORT || 6003}/oauth/callback`;
245
+ }
246
+ }