elpaypg 2.0.5 → 2.0.7

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.
@@ -0,0 +1,88 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
3
+
4
+ <svg
5
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
6
+ xmlns:cc="http://creativecommons.org/ns#"
7
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
8
+ xmlns:svg="http://www.w3.org/2000/svg"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ xmlns:xlink="http://www.w3.org/1999/xlink"
11
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
13
+ version="1.1"
14
+ id="Layer_1"
15
+ viewBox="0 0 1000 324.68351"
16
+ overflow="visible"
17
+ enable-background="new 0 0 659.055 202.068"
18
+ xml:space="preserve"
19
+ inkscape:version="0.48.2 r9819"
20
+ sodipodi:docname="Visa_Logo.svg"
21
+ style="overflow:visible"><metadata
22
+ id="metadata3739"><rdf:RDF><cc:Work
23
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
24
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
25
+ id="defs3737"><linearGradient
26
+ id="linearGradient3801"><stop
27
+ style="stop-color:#20225f;stop-opacity:1"
28
+ offset="0"
29
+ id="stop3803" /><stop
30
+ id="stop3815"
31
+ offset="0.2"
32
+ style="stop-color:#1a1f61;stop-opacity:1" /><stop
33
+ id="stop3813"
34
+ offset="0.41023257"
35
+ style="stop-color:#172272;stop-opacity:1" /><stop
36
+ id="stop3811"
37
+ offset="0.59519672"
38
+ style="stop-color:#152682;stop-opacity:1" /><stop
39
+ id="stop3809"
40
+ offset="0.80208331"
41
+ style="stop-color:#12288e;stop-opacity:1" /><stop
42
+ style="stop-color:#0e2c9a;stop-opacity:1"
43
+ offset="1"
44
+ id="stop3805" /></linearGradient><linearGradient
45
+ inkscape:collect="always"
46
+ xlink:href="#linearGradient3801"
47
+ id="linearGradient3807"
48
+ x1="-60.048428"
49
+ y1="431.60797"
50
+ x2="740.05066"
51
+ y2="431.60797"
52
+ gradientUnits="userSpaceOnUse"
53
+ gradientTransform="matrix(1.2491404,0,0,1.2491404,75.136131,-428.03322)" /></defs><sodipodi:namedview
54
+ pagecolor="#ffffff"
55
+ bordercolor="#666666"
56
+ borderopacity="1"
57
+ objecttolerance="10"
58
+ gridtolerance="10"
59
+ guidetolerance="10"
60
+ inkscape:pageopacity="0"
61
+ inkscape:pageshadow="2"
62
+ inkscape:window-width="1920"
63
+ inkscape:window-height="1028"
64
+ id="namedview3735"
65
+ showgrid="false"
66
+ fit-margin-left="0.5"
67
+ fit-margin-bottom="0.5"
68
+ fit-margin-top="0.5"
69
+ fit-margin-right="0.5"
70
+ inkscape:zoom="0.51625"
71
+ inkscape:cx="838.0749"
72
+ inkscape:cy="-26.853447"
73
+ inkscape:window-x="-8"
74
+ inkscape:window-y="-8"
75
+ inkscape:window-maximized="1"
76
+ inkscape:current-layer="Layer_1"><inkscape:grid
77
+ type="xygrid"
78
+ id="grid3787"
79
+ empspacing="5"
80
+ visible="true"
81
+ enabled="true"
82
+ snapvisiblegridlinesonly="true" /></sodipodi:namedview>
83
+
84
+ <path
85
+ style="fill:url(#linearGradient3807);fill-opacity:1;stroke:none"
86
+ d="m 651.18503,0.50000002 c -70.93272,0 -134.32163,36.76584998 -134.32163,104.69357998 0,77.90028 112.42264,83.28082 112.42264,122.41576 0,16.47806 -18.88384,31.22851 -51.13668,31.22851 -45.77308,0 -79.98403,-20.61081 -79.98403,-20.61081 l -14.63836,68.54658 c 0,0 39.41037,17.40989 91.73375,17.40989 77.55217,0 138.57651,-38.57104 138.57651,-107.66029 0,-82.3157 -112.89106,-87.53633 -112.89106,-123.86008 0,-12.9082 15.50201,-27.05169 47.66251,-27.05169 36.28682,0 65.89216,14.98968 65.89216,14.98968 l 14.32608,-66.20444 c 0,0 -32.21317,-13.89668998 -77.64189,-13.89668998 z M 2.2175605,5.49657 0.49999253,15.48969 c 0,0 29.84159547,5.46149 56.71878047,16.35593 34.606624,12.4927 37.071853,19.7653 42.900167,42.35367 l 63.51098,244.83152 85.13673,0 131.15974,-313.53424 -84.94155,0 L 210.7069,218.67018 176.3165,37.97422 C 173.1626,17.29371 157.18709,5.49657 137.63219,5.49657 l -135.4146295,0 z m 411.8650095,0 -66.63383,313.53424 80.99895,0 66.39962,-313.53424 -80.76474,0 z m 451.75943,0 c -19.53181,0 -29.88045,10.45695 -37.47421,28.73022 l -118.66834,284.80402 84.94155,0 16.434,-47.46734 103.48348,0 9.99312,47.46734 74.94843,0 -65.3847,-313.53424 -68.27333,0 z m 11.04709,84.70733 25.17799,117.65341 -67.45359,0 42.2756,-117.65341 z"
87
+ id="path3789"
88
+ inkscape:connector-curvature="0" /></svg>
@@ -970,19 +970,17 @@
970
970
  let currentSelectedMethod = 'UPI';
971
971
 
972
972
  function hideAllContent() {
973
- document.getElementById('viewUpiContent').hidden = true;
974
- const vCod = document.getElementById('viewCodContent');
975
- if (vCod) vCod.hidden = true;
976
- const vCash = document.getElementById('viewCashContent');
977
- if (vCash) vCash.hidden = true;
978
- const vCard = document.getElementById('viewCardContent');
979
- if (vCard) vCard.hidden = true;
980
- document.getElementById('viewProcessingContent').hidden = true;
981
- document.getElementById('viewSuccessContent').hidden = true;
982
- document.getElementById('viewFailedContent').hidden = true;
983
- document.getElementById('viewQueuedContent').hidden = true;
984
- const vBlocked = document.getElementById('viewBlockedContent');
985
- if (vBlocked) vBlocked.hidden = true;
973
+ const u = document.getElementById('viewUpiContent'); if (u) u.hidden = true;
974
+ const vCod = document.getElementById('viewCodContent'); if (vCod) vCod.hidden = true;
975
+ const vCash = document.getElementById('viewCashContent'); if (vCash) vCash.hidden = true;
976
+ const vCard = document.getElementById('viewCardContent'); if (vCard) vCard.hidden = true;
977
+ const p = document.getElementById('viewProcessingContent'); if (p) p.hidden = true;
978
+ const s = document.getElementById('viewSuccessContent'); if (s) s.hidden = true;
979
+ const f = document.getElementById('viewFailedContent'); if (f) f.hidden = true;
980
+ const q = document.getElementById('viewQueuedContent'); if (q) q.hidden = true;
981
+ const vBlocked = document.getElementById('viewBlockedContent'); if (vBlocked) vBlocked.hidden = true;
982
+ const vDup = document.getElementById('viewDuplicateSessionContent'); if (vDup) vDup.hidden = true;
983
+ }
986
984
  const vDup = document.getElementById('viewDuplicateSessionContent');
987
985
  if (vDup) vDup.hidden = true;
988
986
  }
@@ -1432,6 +1430,21 @@
1432
1430
  }
1433
1431
 
1434
1432
  return { deviceId: null, deviceToken: null };
1433
+ },
1434
+
1435
+ async reset() {
1436
+ this.currentDeviceId = null;
1437
+ this.currentDeviceToken = null;
1438
+ try { localStorage.removeItem('ypg_device_id'); localStorage.removeItem('ypg_device_token'); } catch {}
1439
+ try {
1440
+ const db = await this.openDb();
1441
+ if (db) {
1442
+ const tx = db.transaction(this.storeName, 'readwrite');
1443
+ const store = tx.objectStore(this.storeName);
1444
+ store.delete('primary_device');
1445
+ await new Promise((resolve) => { tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); });
1446
+ }
1447
+ } catch {}
1435
1448
  }
1436
1449
  };
1437
1450
 
@@ -1459,6 +1472,14 @@
1459
1472
  const res = await fetch(`${API_BASE}/api/v1/checkout/${PAYMENT_ID}/state`, { headers });
1460
1473
  const data = await res.json().catch(() => ({}));
1461
1474
 
1475
+ if (data.code === 'DEVICE_REVOKED' || data.code === 'DEVICE_TOKEN_INVALID' || data.code === 'DEVICE_ID_REQUIRED' || data.code === 'DEVICE_CREDENTIALS_REQUIRED') {
1476
+ if (!window._deviceResetAttempted) {
1477
+ window._deviceResetAttempted = true;
1478
+ await DeviceIdentity.reset();
1479
+ return loadState();
1480
+ }
1481
+ }
1482
+
1462
1483
  if (res.status === 409 || data.error === 'DUPLICATE_SESSION_DETECTED') {
1463
1484
  renderDuplicateSession(data);
1464
1485
  dismissSplashScreen();
package/src/index.js CHANGED
@@ -1,137 +1,138 @@
1
- import { EventEmitter } from 'node:events';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { PaymentStatus } from './vendor/common/index.js';
5
- import {
6
- getDatabase,
7
- ensureDatabaseInitialized,
8
- runMigrations,
9
- MerchantRepository,
10
- ReceivingAccountRepository,
11
- CustomerRepository,
12
- CustomerSessionRepository,
13
- PaymentSessionRepository,
14
- ObservationRepository,
15
- SupportTicketRepository,
16
- AuditLogRepository,
17
- DeviceRepository
18
- } from './vendor/db/index.js';
19
- import {
20
- QueueEngine,
21
- TimingEngine,
22
- AttributionEngine,
23
- RecoveryEngine,
24
- WebhookEngine,
25
- PaymentVerificationPipeline
26
- } from './vendor/core/index.js';
27
- import { createRouter } from './router.js';
28
-
29
- const __filename = fileURLToPath(import.meta.url);
30
- const __dirname = path.dirname(__filename);
31
-
32
- export async function elpay(options = {}) {
33
- const db = options.db || getDatabase(options.databaseUrl);
34
- ensureDatabaseInitialized(db);
35
- const { defaultMerchant } = runMigrations(db);
36
-
37
- const merchantRepo = new MerchantRepository(db);
38
- const accountRepo = new ReceivingAccountRepository(db);
39
- const customerRepo = new CustomerRepository(db);
40
- const customerSessionRepo = new CustomerSessionRepository(db, customerRepo);
41
- const sessionRepo = new PaymentSessionRepository(db);
42
- const obsRepo = new ObservationRepository(db);
43
- const ticketRepo = new SupportTicketRepository(db);
44
- const auditRepo = new AuditLogRepository(db);
45
- const deviceRepo = new DeviceRepository(db);
46
-
47
- const eventEmitter = new EventEmitter();
48
-
49
- const parseWindowMs = (raw, fallback) => {
50
- if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
51
- const value = Number(raw);
52
- if (!Number.isFinite(value) || value <= 0 || value > 24 * 60 * 60 * 1000) return fallback;
53
- return Math.floor(value);
54
- };
55
- const activeDurationMs = parseWindowMs(process.env.PAYMENT_ACTIVE_WINDOW_MS, 300000);
56
- const processingDurationMs = parseWindowMs(process.env.PAYMENT_PROCESSING_WINDOW_MS, 30000);
57
-
58
- const queueEngine = new QueueEngine({ sessionRepo, accountRepo, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
59
- const timingEngine = new TimingEngine({ sessionRepo, queueEngine, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
60
- const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo, ticketRepo, auditRepo, eventEmitter });
61
- const recoveryEngine = new RecoveryEngine({ db, sessionRepo, accountRepo, queueEngine, timingEngine, auditRepo, eventEmitter });
62
- const webhookEngine = new WebhookEngine({ db, merchantRepo, maxRetries: Number(process.env.WEBHOOK_MAX_RETRIES || 5) });
63
-
64
- const checkoutBaseUrl = options.baseUrl || process.env.BASE_CHECKOUT_URL || 'http://localhost:3000';
65
-
66
- const handleSlotFreedAndPromote = (receivingAccountId) => {
67
- if (!receivingAccountId) return null;
68
- const promoted = queueEngine.promoteNext(receivingAccountId);
69
- if (promoted) {
70
- if (promoted.expiresAt) timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
71
- }
72
- return promoted;
73
- };
74
-
75
- eventEmitter.on('PAYMENT.SUCCESS', (data) => {
76
- const session = sessionRepo.findById(data.paymentId);
77
- if (session) {
78
- webhookEngine.dispatch('SUCCESS', session).catch(() => {});
79
- handleSlotFreedAndPromote(session.receivingAccountId);
80
- }
81
- if (options.onSuccess) options.onSuccess(data);
82
- });
83
- eventEmitter.on('PAYMENT.FAILED', (data) => {
84
- const session = sessionRepo.findById(data.paymentId);
85
- if (session) {
86
- webhookEngine.dispatch('FAILED', session).catch(() => {});
87
- handleSlotFreedAndPromote(session.receivingAccountId);
88
- }
89
- if (options.onFailed) options.onFailed(data);
90
- });
91
- eventEmitter.on('PAYMENT.EXPIRED', (data) => {
92
- const session = sessionRepo.findById(data.paymentId);
93
- if (session) {
94
- webhookEngine.dispatch('EXPIRED', session).catch(() => {});
95
- handleSlotFreedAndPromote(session.receivingAccountId);
96
- }
97
- });
98
- eventEmitter.on('PAYMENT.CANCELLED', (data) => {
99
- const session = sessionRepo.findById(data.paymentId);
100
- if (session) {
101
- webhookEngine.dispatch('CANCELLED', session).catch(() => {});
102
- handleSlotFreedAndPromote(session.receivingAccountId);
103
- }
104
- });
105
-
106
- recoveryEngine.recover();
107
-
108
- const pipeline = new PaymentVerificationPipeline({
109
- db, sessionRepo, obsRepo, customerRepo, attributionEngine, auditRepo, merchantRepo,
110
- server: null, eventEmitter
111
- });
112
-
113
- const ctx = {
114
- db, merchantRepo, accountRepo, customerRepo, customerSessionRepo, sessionRepo, deviceRepo,
115
- obsRepo, ticketRepo, auditRepo, queueEngine, timingEngine, attributionEngine, pipeline,
116
- recoveryEngine, eventEmitter, defaultMerchant, checkoutBaseUrl, webhookEngine,
117
- otpCooldownMs: options.otpCooldownMs,
118
- theme: options.theme || {}
119
- };
120
-
121
- const router = createRouter(ctx);
122
-
123
- const destroy = () => {
124
- timingEngine.destroy?.();
125
- attributionEngine.destroy?.();
126
- };
127
-
128
- return {
129
- router,
130
- on: (event, cb) => eventEmitter.on(event, cb),
131
- init: async () => {},
132
- destroy,
133
- };
134
- }
135
-
136
- export { createRouter };
137
- export default { elpay };
1
+ import { EventEmitter } from 'node:events';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { PaymentStatus } from './vendor/common/index.js';
5
+ import {
6
+ getDatabase,
7
+ ensureDatabaseInitialized,
8
+ runMigrations,
9
+ MerchantRepository,
10
+ ReceivingAccountRepository,
11
+ CustomerRepository,
12
+ CustomerSessionRepository,
13
+ PaymentSessionRepository,
14
+ ObservationRepository,
15
+ SupportTicketRepository,
16
+ AuditLogRepository,
17
+ DeviceRepository
18
+ } from './vendor/db/index.js';
19
+ import {
20
+ QueueEngine,
21
+ TimingEngine,
22
+ AttributionEngine,
23
+ RecoveryEngine,
24
+ WebhookEngine,
25
+ PaymentVerificationPipeline
26
+ } from './vendor/core/index.js';
27
+ import { createRouter } from './router.js';
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+
32
+ export async function elpay(options = {}) {
33
+ const db = options.db || getDatabase(options.databaseUrl);
34
+ ensureDatabaseInitialized(db);
35
+ const { defaultMerchant } = runMigrations(db);
36
+
37
+ const merchantRepo = new MerchantRepository(db);
38
+ const accountRepo = new ReceivingAccountRepository(db);
39
+ const customerRepo = new CustomerRepository(db);
40
+ const customerSessionRepo = new CustomerSessionRepository(db, customerRepo);
41
+ const sessionRepo = new PaymentSessionRepository(db);
42
+ const obsRepo = new ObservationRepository(db);
43
+ const ticketRepo = new SupportTicketRepository(db);
44
+ const auditRepo = new AuditLogRepository(db);
45
+ const deviceRepo = new DeviceRepository(db);
46
+
47
+ const eventEmitter = new EventEmitter();
48
+
49
+ const parseWindowMs = (raw, fallback) => {
50
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
51
+ const value = Number(raw);
52
+ if (!Number.isFinite(value) || value <= 0 || value > 24 * 60 * 60 * 1000) return fallback;
53
+ return Math.floor(value);
54
+ };
55
+ const activeDurationMs = parseWindowMs(process.env.PAYMENT_ACTIVE_WINDOW_MS, 300000);
56
+ const processingDurationMs = parseWindowMs(process.env.PAYMENT_PROCESSING_WINDOW_MS, 30000);
57
+
58
+ const queueEngine = new QueueEngine({ sessionRepo, accountRepo, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
59
+ const timingEngine = new TimingEngine({ sessionRepo, queueEngine, eventEmitter, auditRepo, activeDurationMs, processingDurationMs });
60
+ const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo, ticketRepo, auditRepo, eventEmitter });
61
+ const recoveryEngine = new RecoveryEngine({ db, sessionRepo, accountRepo, queueEngine, timingEngine, auditRepo, eventEmitter });
62
+ const webhookEngine = new WebhookEngine({ db, merchantRepo, maxRetries: Number(process.env.WEBHOOK_MAX_RETRIES || 5) });
63
+
64
+ const checkoutBaseUrl = options.baseUrl || process.env.BASE_CHECKOUT_URL || 'http://localhost:3000';
65
+
66
+ const handleSlotFreedAndPromote = (receivingAccountId) => {
67
+ if (!receivingAccountId) return null;
68
+ const promoted = queueEngine.promoteNext(receivingAccountId);
69
+ if (promoted) {
70
+ if (promoted.expiresAt) timingEngine.startActiveTimer(promoted.id, promoted.expiresAt);
71
+ }
72
+ return promoted;
73
+ };
74
+
75
+ eventEmitter.on('PAYMENT.SUCCESS', (data) => {
76
+ const session = sessionRepo.findById(data.paymentId);
77
+ if (session) {
78
+ webhookEngine.dispatch('SUCCESS', session).catch(() => {});
79
+ handleSlotFreedAndPromote(session.receivingAccountId);
80
+ }
81
+ if (options.onSuccess) options.onSuccess(data);
82
+ });
83
+ eventEmitter.on('PAYMENT.FAILED', (data) => {
84
+ const session = sessionRepo.findById(data.paymentId);
85
+ if (session) {
86
+ webhookEngine.dispatch('FAILED', session).catch(() => {});
87
+ handleSlotFreedAndPromote(session.receivingAccountId);
88
+ }
89
+ if (options.onFailed) options.onFailed(data);
90
+ });
91
+ eventEmitter.on('PAYMENT.EXPIRED', (data) => {
92
+ const session = sessionRepo.findById(data.paymentId);
93
+ if (session) {
94
+ webhookEngine.dispatch('EXPIRED', session).catch(() => {});
95
+ handleSlotFreedAndPromote(session.receivingAccountId);
96
+ }
97
+ });
98
+ eventEmitter.on('PAYMENT.CANCELLED', (data) => {
99
+ const session = sessionRepo.findById(data.paymentId);
100
+ if (session) {
101
+ webhookEngine.dispatch('CANCELLED', session).catch(() => {});
102
+ handleSlotFreedAndPromote(session.receivingAccountId);
103
+ }
104
+ });
105
+
106
+ recoveryEngine.recover();
107
+
108
+ const pipeline = new PaymentVerificationPipeline({
109
+ db, sessionRepo, obsRepo, customerRepo, attributionEngine, auditRepo, merchantRepo,
110
+ server: null, eventEmitter
111
+ });
112
+
113
+ const ctx = {
114
+ db, merchantRepo, accountRepo, customerRepo, customerSessionRepo, sessionRepo, deviceRepo,
115
+ obsRepo, ticketRepo, auditRepo, queueEngine, timingEngine, attributionEngine, pipeline,
116
+ recoveryEngine, eventEmitter, defaultMerchant, checkoutBaseUrl, webhookEngine,
117
+ baseUrl: options.baseUrl || process.env.BASE_URL || 'http://localhost:5000',
118
+ otpCooldownMs: options.otpCooldownMs,
119
+ theme: options.theme || {}
120
+ };
121
+
122
+ const router = createRouter(ctx);
123
+
124
+ const destroy = () => {
125
+ timingEngine.destroy?.();
126
+ attributionEngine.destroy?.();
127
+ };
128
+
129
+ return {
130
+ router,
131
+ on: (event, cb) => eventEmitter.on(event, cb),
132
+ init: async () => {},
133
+ destroy,
134
+ };
135
+ }
136
+
137
+ export { createRouter };
138
+ export default { elpay };
package/src/router.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Router } from 'express';
2
+ import { server } from './sse.js';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import fs from 'node:fs';
5
6
  import express from 'express';
7
+ import { getInternalServiceToken } from './vendor/common/index.js';
6
8
  import { registerPaymentRoutes } from './routes/payments.routes.js';
7
9
  import { registerCheckoutRoutes } from './routes/checkout.routes.js';
8
10
  import { registerPollerRoutes } from './routes/pollers.routes.js';
@@ -12,15 +14,60 @@ import { registerDeviceRoutes } from './routes/device.routes.js';
12
14
 
13
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
16
 
17
+ function loadTemplateWithPartials() {
18
+ const viewsDir = path.resolve(__dirname, '..', 'public', 'views');
19
+ const partialsDir = path.resolve(viewsDir, 'partials');
20
+ let content = fs.readFileSync(path.join(viewsDir, 'checkout.html'), 'utf8');
21
+ content = content.replace(/<!--\s*include:partials\/([a-zA-Z0-9_\-.]+)\s*-->/g, (match, filename) => {
22
+ const partialPath = path.resolve(partialsDir, filename);
23
+ const relative = path.relative(partialsDir, partialPath);
24
+ const isSecure = relative && !relative.startsWith('..') && !path.isAbsolute(relative);
25
+ if (isSecure && fs.existsSync(partialPath)) {
26
+ return fs.readFileSync(partialPath, 'utf8');
27
+ }
28
+ return match;
29
+ });
30
+ return content;
31
+ }
32
+
33
+ function renderTemplate(template, values) {
34
+ const escapeHtml = (v) => (v === null || v === undefined) ? '' : String(v).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
35
+ const escapeJs = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
36
+ const scriptKeys = new Set(['PAYMENT_ID', 'API_BASE_URL', 'AMOUNT']);
37
+ return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
38
+ if (!Object.prototype.hasOwnProperty.call(values, key)) return match;
39
+ const value = values[key];
40
+ return scriptKeys.has(key) ? escapeJs(value) : escapeHtml(value);
41
+ });
42
+ }
43
+
44
+ function serializeForScript(value) {
45
+ return JSON.stringify(value === undefined ? null : value)
46
+ .replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
47
+ .replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
48
+ }
49
+
50
+ async function fetchCheckoutState(ctx, paymentId) {
51
+ try {
52
+ const base = (ctx.baseUrl || ctx.checkoutBaseUrl || 'http://127.0.0.1:5000').replace(/\/$/, '');
53
+ const token = getInternalServiceToken();
54
+ const res = await fetch(`${base}/api/v1/checkout/${encodeURIComponent(paymentId)}/state`, {
55
+ headers: { Authorization: `Bearer ${token}`, 'X-Internal-Render': 'true' },
56
+ signal: AbortSignal.timeout(4000)
57
+ });
58
+ if (res.ok) return await res.json();
59
+ } catch {}
60
+ return null;
61
+ }
62
+
15
63
  export function createRouter(ctx = {}) {
16
64
  const router = Router();
17
65
  router.use(express.static(path.join(__dirname, '..', 'public')));
18
66
 
19
- router.get('/checkout/:id', (req, res) => {
20
- const htmlPath = path.join(__dirname, '..', 'public', 'views', 'checkout.html');
21
- const html = fs.readFileSync(htmlPath, 'utf8')
22
- .replace(/\{\{API_BASE_URL\}\}/g, ctx.baseUrl || 'http://localhost:5000')
23
- .replace(/\{\{PAYMENT_ID\}\}/g, req.params.id);
67
+ router.get('/checkout/:id', async (req, res) => {
68
+ const paymentId = req.params.id;
69
+ const state = await fetchCheckoutState(ctx, paymentId);
70
+ const html = renderCheckoutPage(ctx, paymentId, state);
24
71
  res.type('html').send(html);
25
72
  });
26
73
 
@@ -50,4 +97,17 @@ export function createRouter(ctx = {}) {
50
97
  return router;
51
98
  }
52
99
 
100
+ function renderCheckoutPage(ctx, paymentId, state) {
101
+ const template = loadTemplateWithPartials();
102
+ const values = {
103
+ PAYMENT_ID: paymentId,
104
+ API_BASE_URL: '',
105
+ APP_NAME: state?.branding?.appName || 'ELPAY PG',
106
+ MERCHANT_NAME: state?.merchantName || 'Merchant Checkout',
107
+ AMOUNT: state?.amount || '0.00',
108
+ INITIAL_STATE_JSON: serializeForScript(state || {})
109
+ };
110
+ return renderTemplate(template, values);
111
+ }
112
+
53
113
  export default createRouter;
@@ -1,4 +1,5 @@
1
- import crypto from 'node:crypto';
1
+ import crypto from 'node:crypto';
2
+ import { server } from '../sse.js';
2
3
  import { Router } from 'express';
3
4
  import { PaymentStatus, PaymentMethod, SellingMode, AuditActorType, isValidVpa, toRupeesString, deriveCloudflareRoutes, resolveRuntimeUrls, resolveGmailRedirectUri, createOAuthState, validateOutboundWebhookUrl } from '../vendor/common/index.js';
4
5
  import { PaymentVerificationPipeline } from '../vendor/core/index.js';
@@ -1120,10 +1121,7 @@ export function registerAdminRoutes(router, ctx) {
1120
1121
 
1121
1122
  // GET /api/v1/admin/stream (SSE for Live Admin Counter & Queue Events)
1122
1123
  router.get('/api/v1/admin/stream', async (req, res) => {
1123
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
1124
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1125
- const _iv = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1126
- req.on('close', () => clearInterval(_iv));
1124
+ server.initSse('admin_counter_events', res);
1127
1125
  });
1128
1126
 
1129
1127
  // --- WHATSAPP BERRY PROTOCOL MERCHANT SESSION CONFIGURATION ---
@@ -1,4 +1,5 @@
1
- import { Router } from 'express';
1
+ import { Router } from 'express';
2
+ import { server } from '../sse.js';
2
3
  import { toRupeesString, buildUpiUri, PaymentStatus, PaymentMethod, UPI_PROVIDERS, isInternalServiceRequest, isValidTransition } from '../vendor/common/index.js';
3
4
  import { validateSessionDeviceAccess, verifyRegisteredDeviceCredentials } from '../vendor/core/index.js';
4
5
  import { isPaymentOwner } from '../auth-guard.js';
@@ -1961,15 +1962,7 @@ export function registerCheckoutRoutes(router, ctx) {
1961
1962
  res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1962
1963
  return;
1963
1964
  }
1964
- res.writeHead(200, {
1965
- 'Content-Type': 'text/event-stream',
1966
- 'Cache-Control': 'no-cache',
1967
- 'Connection': 'keep-alive',
1968
- 'Access-Control-Allow-Origin': '*'
1969
- });
1970
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1971
- const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1972
- req.on('close', () => clearInterval(interval));
1965
+ server.initSse(req.params.id, res);
1973
1966
  });
1974
1967
 
1975
1968
  router.get('/api/v1/checkout/:id/events', async (req, res) => {
@@ -1983,15 +1976,7 @@ export function registerCheckoutRoutes(router, ctx) {
1983
1976
  res.status(deviceAccess.statusCode || 409).json(deviceAccess.body);
1984
1977
  return;
1985
1978
  }
1986
- res.writeHead(200, {
1987
- 'Content-Type': 'text/event-stream',
1988
- 'Cache-Control': 'no-cache',
1989
- 'Connection': 'keep-alive',
1990
- 'Access-Control-Allow-Origin': '*'
1991
- });
1992
- res.write('event: connected\ndata: {"status":"connected"}\n\n');
1993
- const interval = setInterval(() => { try { res.write(': keepalive\n\n'); } catch {} }, 15000);
1994
- req.on('close', () => clearInterval(interval));
1979
+ server.initSse(req.params.id, res);
1995
1980
  });
1996
1981
  }
1997
1982
 
package/src/sse.js ADDED
@@ -0,0 +1,44 @@
1
+ const channels = new Map();
2
+
3
+ export const server = {
4
+ initSse(topic, res) {
5
+ const headers = {
6
+ 'Content-Type': 'text/event-stream',
7
+ 'Cache-Control': 'no-cache',
8
+ 'Connection': 'keep-alive',
9
+ 'Access-Control-Allow-Origin': '*'
10
+ };
11
+ res.writeHead(200, headers);
12
+ res.write('event: connected\ndata: {"status":"connected"}\n\n');
13
+
14
+ if (!channels.has(topic)) channels.set(topic, new Set());
15
+ const set = channels.get(topic);
16
+ set.add(res);
17
+
18
+ const ping = setInterval(() => {
19
+ try { res.write(': keepalive\n\n'); } catch { clearInterval(ping); }
20
+ }, 15000);
21
+ if (ping.unref) ping.unref();
22
+
23
+ res.on('close', () => {
24
+ clearInterval(ping);
25
+ set.delete(res);
26
+ if (set.size === 0) channels.delete(topic);
27
+ });
28
+ },
29
+
30
+ broadcastSse(topic, event, data) {
31
+ const set = channels.get(topic);
32
+ if (!set || set.size === 0) return;
33
+ let safeJson;
34
+ try { safeJson = JSON.stringify(data, (k, v) => typeof v === 'bigint' ? v.toString() : v); } catch { safeJson = '{}'; }
35
+ const named = `event: ${event}\ndata: ${safeJson}\n\n`;
36
+ const def = `data: ${JSON.stringify({ type: event, ...data, event })}\n\n`;
37
+ const payload = named + def;
38
+ for (const r of set) {
39
+ try { r.write(payload); } catch { set.delete(r); }
40
+ }
41
+ }
42
+ };
43
+
44
+ export default server;