elpaypg 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2421 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- package/src/vendor/db/sqlite-schema.sql +270 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { getObserverToken } from './../vendor/common/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ObserverPollerService
|
|
5
|
+
*
|
|
6
|
+
* Consolidated, coordinated background poller and verification daemon.
|
|
7
|
+
* Replaces duplicate, uncoordinated polling loops with a single unified orchestrator:
|
|
8
|
+
* - Automatically idles when no active/processing UPI sessions exist.
|
|
9
|
+
* - Enforces minimum 1.5s–2.0s cooldown between external observer sweeps (SMS and Gmail).
|
|
10
|
+
* - Drops redundant in-flight HTTP requests to prevent observer socket exhaustion.
|
|
11
|
+
* - Serializes observation attribution with a mutex lock to eliminate SQLite lock contention (SQLITE_BUSY).
|
|
12
|
+
* - Reacts immediately to payment events (SESSION.PROCESSING, SESSION.ACTIVATED) without waiting for timer ticks.
|
|
13
|
+
*/
|
|
14
|
+
export class ObserverPollerService {
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* db: import('../vendor/db/index.js').Database,
|
|
18
|
+
* sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
|
|
19
|
+
* obsRepo: import('../vendor/db/index.js').ObservationRepository,
|
|
20
|
+
* attributionEngine: import('../vendor/core/index.js').AttributionEngine,
|
|
21
|
+
* eventEmitter: import('node:events').EventEmitter,
|
|
22
|
+
* pollIntervalMs?: number,
|
|
23
|
+
* minSweepCooldownMs?: number,
|
|
24
|
+
* smsPort?: number,
|
|
25
|
+
* gmailPort?: number,
|
|
26
|
+
* observerToken?: string,
|
|
27
|
+
* autoStart?: boolean
|
|
28
|
+
* }} options
|
|
29
|
+
*/
|
|
30
|
+
constructor(options) {
|
|
31
|
+
if (!options || !options.db || !options.sessionRepo || !options.obsRepo || !options.attributionEngine || !options.eventEmitter) {
|
|
32
|
+
throw new Error('ObserverPollerService requires db, sessionRepo, obsRepo, attributionEngine, and eventEmitter');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.db = options.db;
|
|
36
|
+
this.sessionRepo = options.sessionRepo;
|
|
37
|
+
this.obsRepo = options.obsRepo;
|
|
38
|
+
this.attributionEngine = options.attributionEngine;
|
|
39
|
+
this.eventEmitter = options.eventEmitter;
|
|
40
|
+
|
|
41
|
+
// Configurable timing parameters
|
|
42
|
+
this.pollIntervalMs = Number(options.pollIntervalMs || process.env.OBSERVER_POLL_INTERVAL_MS || 1500);
|
|
43
|
+
this.minSweepCooldownMs = Number(options.minSweepCooldownMs || process.env.OBSERVER_SWEEP_COOLDOWN_MS || 2000);
|
|
44
|
+
this.smsPort = Number(options.smsPort || process.env.SMS_OBSERVER_PORT || 6002);
|
|
45
|
+
this.gmailPort = Number(options.gmailPort || process.env.GMAIL_OBSERVER_PORT || 6003);
|
|
46
|
+
this.observerToken = options.observerToken || null;
|
|
47
|
+
|
|
48
|
+
// Lifecycle and concurrency control state
|
|
49
|
+
this._isRunning = false;
|
|
50
|
+
this._isPollingCycle = false;
|
|
51
|
+
this._isIdle = true;
|
|
52
|
+
this._timer = null;
|
|
53
|
+
|
|
54
|
+
// Cooldown and in-flight tracking for observer endpoints
|
|
55
|
+
this._lastSmsSweepTime = 0;
|
|
56
|
+
this._lastGmailSweepTime = 0;
|
|
57
|
+
this._isSmsSweepInFlight = false;
|
|
58
|
+
this._isGmailSweepInFlight = false;
|
|
59
|
+
|
|
60
|
+
// Attribution mutex & in-flight hash set to prevent SQLite write contention
|
|
61
|
+
this._isAttributionInProgress = false;
|
|
62
|
+
this._inFlightObservationHashes = new Set();
|
|
63
|
+
|
|
64
|
+
// Event listener bindings
|
|
65
|
+
this._boundOnSessionProcessing = this.handleSessionProcessing.bind(this);
|
|
66
|
+
this._boundOnSessionActivated = this.handleSessionActivated.bind(this);
|
|
67
|
+
this._boundOnPaymentTerminal = this.handlePaymentTerminal.bind(this);
|
|
68
|
+
|
|
69
|
+
if (options.autoStart !== false) {
|
|
70
|
+
this.start();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get isRunning() {
|
|
75
|
+
return this._isRunning;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get isIdle() {
|
|
79
|
+
return this._isIdle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get isSmsSweepInFlight() {
|
|
83
|
+
return this._isSmsSweepInFlight;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get isGmailSweepInFlight() {
|
|
87
|
+
return this._isGmailSweepInFlight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get isAttributionInProgress() {
|
|
91
|
+
return this._isAttributionInProgress;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get lastSmsSweepTime() {
|
|
95
|
+
return this._lastSmsSweepTime;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get lastGmailSweepTime() {
|
|
99
|
+
return this._lastGmailSweepTime;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Starts the consolidated poller daemon and binds event listeners.
|
|
104
|
+
*/
|
|
105
|
+
start() {
|
|
106
|
+
if (this._isRunning) return;
|
|
107
|
+
this._isRunning = true;
|
|
108
|
+
|
|
109
|
+
// Bind reactive lifecycle events
|
|
110
|
+
this.eventEmitter.on('SESSION.PROCESSING', this._boundOnSessionProcessing);
|
|
111
|
+
this.eventEmitter.on('SESSION.ACTIVATED', this._boundOnSessionActivated);
|
|
112
|
+
this.eventEmitter.on('PAYMENT.SUCCESS', this._boundOnPaymentTerminal);
|
|
113
|
+
this.eventEmitter.on('PAYMENT.FAILED', this._boundOnPaymentTerminal);
|
|
114
|
+
this.eventEmitter.on('PAYMENT.EXPIRED', this._boundOnPaymentTerminal);
|
|
115
|
+
this.eventEmitter.on('PAYMENT.CANCELLED', this._boundOnPaymentTerminal);
|
|
116
|
+
|
|
117
|
+
// Start single consolidated daemon interval
|
|
118
|
+
this._timer = setInterval(() => {
|
|
119
|
+
this.pollCycle().catch(() => {});
|
|
120
|
+
}, this.pollIntervalMs);
|
|
121
|
+
|
|
122
|
+
if (this._timer.unref) {
|
|
123
|
+
this._timer.unref();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Initial check
|
|
127
|
+
this.pollCycle().catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stops the daemon, cleans up timers, and removes event listeners.
|
|
132
|
+
*/
|
|
133
|
+
stop() {
|
|
134
|
+
this._isRunning = false;
|
|
135
|
+
|
|
136
|
+
if (this._timer) {
|
|
137
|
+
clearInterval(this._timer);
|
|
138
|
+
this._timer = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.eventEmitter) {
|
|
142
|
+
this.eventEmitter.removeListener('SESSION.PROCESSING', this._boundOnSessionProcessing);
|
|
143
|
+
this.eventEmitter.removeListener('SESSION.ACTIVATED', this._boundOnSessionActivated);
|
|
144
|
+
this.eventEmitter.removeListener('PAYMENT.SUCCESS', this._boundOnPaymentTerminal);
|
|
145
|
+
this.eventEmitter.removeListener('PAYMENT.FAILED', this._boundOnPaymentTerminal);
|
|
146
|
+
this.eventEmitter.removeListener('PAYMENT.EXPIRED', this._boundOnPaymentTerminal);
|
|
147
|
+
this.eventEmitter.removeListener('PAYMENT.CANCELLED', this._boundOnPaymentTerminal);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this._inFlightObservationHashes.clear();
|
|
151
|
+
this._isPollingCycle = false;
|
|
152
|
+
this._isAttributionInProgress = false;
|
|
153
|
+
this._isSmsSweepInFlight = false;
|
|
154
|
+
this._isGmailSweepInFlight = false;
|
|
155
|
+
this._isIdle = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Alias for stop() for standard engine lifecycle parity.
|
|
160
|
+
*/
|
|
161
|
+
destroy() {
|
|
162
|
+
this.stop();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reactive handler when a customer initiates payment confirmation.
|
|
167
|
+
* Wakes up the poller immediately without waiting for the next interval tick.
|
|
168
|
+
* @param {{ sessionId?: string, receivingAccountId?: string }} data
|
|
169
|
+
*/
|
|
170
|
+
handleSessionProcessing(data) {
|
|
171
|
+
this.triggerExpeditedSweep(data?.receivingAccountId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reactive handler when a new session transitions to ACTIVE.
|
|
176
|
+
* @param {{ sessionId?: string, receivingAccountId?: string }} data
|
|
177
|
+
*/
|
|
178
|
+
handleSessionActivated(data) {
|
|
179
|
+
this._isIdle = false;
|
|
180
|
+
this.triggerExpeditedSweep(data?.receivingAccountId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reactive handler on terminal payment outcomes.
|
|
185
|
+
*/
|
|
186
|
+
handlePaymentTerminal() {
|
|
187
|
+
// Next poll cycle will automatically update idle state
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Immediately triggers a poll sweep, waking the poller if idle.
|
|
192
|
+
* @param {string} [preferredAccountId]
|
|
193
|
+
*/
|
|
194
|
+
triggerExpeditedSweep(preferredAccountId = null) {
|
|
195
|
+
if (!this._isRunning || this._isPollingCycle) return;
|
|
196
|
+
this.pollCycle(preferredAccountId).catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Finds all active payment sessions waiting for bank verification.
|
|
201
|
+
* Includes ACTIVE, PROCESSING, and recent SUCCESS sessions awaiting secondary channel dual/triple match.
|
|
202
|
+
* @returns {Array<{ id: string, receiving_account_id: string, amount_paise: number, status: string, verification_level?: number }>}
|
|
203
|
+
*/
|
|
204
|
+
getActiveSessions() {
|
|
205
|
+
try {
|
|
206
|
+
const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
|
|
207
|
+
// A session that already reached the Triple Verification Standard (level 3) is fully
|
|
208
|
+
// confirmed: sweeping it again can never change its outcome, so it is excluded. This
|
|
209
|
+
// preserves the upper bound the previous per-session sweep timers enforced.
|
|
210
|
+
return this.db.query(`
|
|
211
|
+
SELECT id, receiving_account_id, amount_paise, status, verification_level
|
|
212
|
+
FROM payment_sessions
|
|
213
|
+
WHERE method = 'UPI' AND (
|
|
214
|
+
(status IN ('ACTIVE', 'PROCESSING') AND COALESCE(verification_level, 0) < 3)
|
|
215
|
+
OR (status = 'SUCCESS' AND COALESCE(verification_level, 0) < 2 AND completed_at >= ?)
|
|
216
|
+
)
|
|
217
|
+
`, [sixtySecsAgo]);
|
|
218
|
+
} catch {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Executes a single coordinated polling cycle:
|
|
225
|
+
* 1. Evaluates active sessions; idles immediately if none are active.
|
|
226
|
+
* 2. Fires throttled sweeps to SMS and Gmail observers (respecting cooldown & in-flight lock).
|
|
227
|
+
* 3. Sequentially processes unattributed bank observations under mutex lock.
|
|
228
|
+
* @param {string} [preferredAccountId]
|
|
229
|
+
*/
|
|
230
|
+
async pollCycle(preferredAccountId = null) {
|
|
231
|
+
if (!this._isRunning || this._isPollingCycle) return;
|
|
232
|
+
this._isPollingCycle = true;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const activeSessions = this.getActiveSessions();
|
|
236
|
+
|
|
237
|
+
if (activeSessions.length === 0) {
|
|
238
|
+
this._isIdle = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this._isIdle = false;
|
|
243
|
+
|
|
244
|
+
// 1. Concurrently trigger external observer sweeps with strict cooldown & in-flight locks
|
|
245
|
+
await Promise.allSettled([
|
|
246
|
+
this.sweepSmsObserver(),
|
|
247
|
+
this.sweepGmailObserver()
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
// 2. Process pending unattributed bank observations in DB under sequential mutex lock
|
|
251
|
+
await this.processPendingAttributions(activeSessions, preferredAccountId);
|
|
252
|
+
} finally {
|
|
253
|
+
this._isPollingCycle = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Sweeps Google Messages SMS Observer with cooldown and in-flight protection.
|
|
259
|
+
* Drops redundant calls if cooldown has not elapsed or another sweep is currently in flight.
|
|
260
|
+
* @returns {Promise<boolean>} True if sweep was dispatched, false if dropped/in-flight/cooldown.
|
|
261
|
+
*/
|
|
262
|
+
async sweepSmsObserver() {
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (this._isSmsSweepInFlight || (now - this._lastSmsSweepTime < this.minSweepCooldownMs)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this._isSmsSweepInFlight = true;
|
|
269
|
+
this._lastSmsSweepTime = now;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const token = this.observerToken || getObserverToken();
|
|
273
|
+
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
274
|
+
|
|
275
|
+
await fetch(`http://localhost:${this.smsPort}/api/sweep`, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers,
|
|
278
|
+
signal: AbortSignal.timeout(1200)
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
} finally {
|
|
284
|
+
this._isSmsSweepInFlight = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sweeps Gmail Observer with cooldown and in-flight protection.
|
|
290
|
+
* Drops redundant calls if cooldown has not elapsed or another sweep is currently in flight.
|
|
291
|
+
* @returns {Promise<boolean>} True if sweep was dispatched, false if dropped/in-flight/cooldown.
|
|
292
|
+
*/
|
|
293
|
+
async sweepGmailObserver() {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (this._isGmailSweepInFlight || (now - this._lastGmailSweepTime < this.minSweepCooldownMs)) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this._isGmailSweepInFlight = true;
|
|
300
|
+
this._lastGmailSweepTime = now;
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const token = this.observerToken || getObserverToken();
|
|
304
|
+
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
|
305
|
+
|
|
306
|
+
await fetch(`http://localhost:${this.gmailPort}/api/sweep`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers,
|
|
309
|
+
signal: AbortSignal.timeout(1200)
|
|
310
|
+
});
|
|
311
|
+
return true;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
} finally {
|
|
315
|
+
this._isGmailSweepInFlight = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Processes unattributed observations for active accounts under strict sequential mutex lock.
|
|
321
|
+
* Deduplicates receivingAccountIds and tracks in-flight observation hashes to prevent
|
|
322
|
+
* concurrent SQLite write transactions and SQLITE_BUSY contention.
|
|
323
|
+
* @param {Array<{ receiving_account_id: string }>} activeSessions
|
|
324
|
+
* @param {string} [preferredAccountId]
|
|
325
|
+
*/
|
|
326
|
+
async processPendingAttributions(activeSessions, preferredAccountId = null) {
|
|
327
|
+
if (this._isAttributionInProgress) return;
|
|
328
|
+
this._isAttributionInProgress = true;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const rawAccountIds = activeSessions.map(s => s.receiving_account_id).filter(Boolean);
|
|
332
|
+
const accountIds = [...new Set(rawAccountIds)];
|
|
333
|
+
|
|
334
|
+
if (preferredAccountId && accountIds.includes(preferredAccountId)) {
|
|
335
|
+
accountIds.sort((a, b) => a === preferredAccountId ? -1 : (b === preferredAccountId ? 1 : 0));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const accountId of accountIds) {
|
|
339
|
+
const unattributed = this.obsRepo.findUnattributed(accountId);
|
|
340
|
+
if (!Array.isArray(unattributed) || unattributed.length === 0) continue;
|
|
341
|
+
|
|
342
|
+
for (const obs of unattributed) {
|
|
343
|
+
const hash = obs.fingerprintHash || obs.id;
|
|
344
|
+
if (this._inFlightObservationHashes.has(hash)) continue;
|
|
345
|
+
|
|
346
|
+
this._inFlightObservationHashes.add(hash);
|
|
347
|
+
try {
|
|
348
|
+
this.attributionEngine.processObservation({
|
|
349
|
+
source: obs.source,
|
|
350
|
+
receivingAccountId: obs.receivingAccountId,
|
|
351
|
+
amountPaise: obs.amountPaise,
|
|
352
|
+
observedAt: obs.observedAt,
|
|
353
|
+
bankReferenceNumber: obs.bankReferenceNumber,
|
|
354
|
+
senderVpa: obs.senderVpa,
|
|
355
|
+
rawPayload: obs.rawPayload,
|
|
356
|
+
fingerprintHash: obs.fingerprintHash
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
// Silently absorb individual observation attribution failures to avoid interrupting poller loop
|
|
360
|
+
} finally {
|
|
361
|
+
this._inFlightObservationHashes.delete(hash);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
this._isAttributionInProgress = false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { buildCloudflaredInvocation } from './../vendor/common/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare Tunnel Process Manager
|
|
7
|
+
* Manages running user-provided cloudflared commands in the background with live log streaming.
|
|
8
|
+
*/
|
|
9
|
+
export class TunnelManager extends EventEmitter {
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
this.process = null;
|
|
13
|
+
this.status = 'STOPPED'; // 'STOPPED' | 'STARTING' | 'RUNNING' | 'ERROR'
|
|
14
|
+
this.command = null;
|
|
15
|
+
this.logs = [];
|
|
16
|
+
this.maxLogs = 200;
|
|
17
|
+
this.pid = null;
|
|
18
|
+
this.startedAt = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
appendLog(line) {
|
|
22
|
+
const entry = {
|
|
23
|
+
timestamp: new Date().toISOString(),
|
|
24
|
+
text: line.trim()
|
|
25
|
+
};
|
|
26
|
+
this.logs.push(entry);
|
|
27
|
+
if (this.logs.length > this.maxLogs) {
|
|
28
|
+
this.logs.shift();
|
|
29
|
+
}
|
|
30
|
+
this.emit('log', entry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getStatus() {
|
|
34
|
+
return {
|
|
35
|
+
status: this.status,
|
|
36
|
+
command: this.command, // redacted: never contains the token
|
|
37
|
+
pid: this.pid,
|
|
38
|
+
startedAt: this.startedAt,
|
|
39
|
+
isRunning: this.status === 'RUNNING' || this.status === 'STARTING',
|
|
40
|
+
recentLogs: this.logs.slice(-50)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
start(rawCommand) {
|
|
45
|
+
if (!rawCommand || !rawCommand.trim()) {
|
|
46
|
+
throw new Error('Cloudflare Tunnel command cannot be empty.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (this.process && (this.status === 'RUNNING' || this.status === 'STARTING')) {
|
|
50
|
+
this.stop();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Only the extracted token is used; the pasted text is never run through a shell.
|
|
54
|
+
const invocation = buildCloudflaredInvocation(rawCommand);
|
|
55
|
+
this.command = invocation.redacted;
|
|
56
|
+
this.status = 'STARTING';
|
|
57
|
+
this.startedAt = new Date().toISOString();
|
|
58
|
+
this.appendLog(`[TunnelManager] Launching tunnel: ${invocation.redacted}`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn(invocation.file, invocation.args, {
|
|
62
|
+
shell: false,
|
|
63
|
+
detached: false,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
65
|
+
env: { ...process.env }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.process = child;
|
|
69
|
+
this.pid = child.pid;
|
|
70
|
+
this.status = 'RUNNING';
|
|
71
|
+
|
|
72
|
+
child.stdout.on('data', data => {
|
|
73
|
+
const text = data.toString();
|
|
74
|
+
this.appendLog(`[STDOUT] ${text}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
child.stderr.on('data', data => {
|
|
78
|
+
const text = data.toString();
|
|
79
|
+
this.appendLog(`[STDERR] ${text}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.on('error', err => {
|
|
83
|
+
this.status = 'ERROR';
|
|
84
|
+
this.appendLog(`[ERROR] Process failed to spawn: ${err.message}`);
|
|
85
|
+
this.emit('error', err);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
child.on('close', code => {
|
|
89
|
+
this.appendLog(`[EXIT] Tunnel process exited with code ${code}`);
|
|
90
|
+
this.status = 'STOPPED';
|
|
91
|
+
this.process = null;
|
|
92
|
+
this.pid = null;
|
|
93
|
+
this.emit('exit', code);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return this.getStatus();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
this.status = 'ERROR';
|
|
99
|
+
this.appendLog(`[ERROR] Exception spawning tunnel: ${err.message}`);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
stop() {
|
|
105
|
+
if (!this.process) {
|
|
106
|
+
this.status = 'STOPPED';
|
|
107
|
+
this.pid = null;
|
|
108
|
+
return this.getStatus();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.appendLog(`[TunnelManager] Stopping tunnel process (PID: ${this.pid})...`);
|
|
112
|
+
try {
|
|
113
|
+
if (process.platform === 'win32' && this.pid) {
|
|
114
|
+
spawn('taskkill', ['/pid', String(this.pid), '/f', '/t']);
|
|
115
|
+
} else if (this.pid) {
|
|
116
|
+
process.kill(this.pid, 'SIGTERM');
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
this.appendLog(`[TunnelManager] Kill error notice: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.process = null;
|
|
123
|
+
this.status = 'STOPPED';
|
|
124
|
+
this.pid = null;
|
|
125
|
+
return this.getStatus();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Global singleton instance
|
|
130
|
+
export const globalTunnelManager = new TunnelManager();
|