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,653 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Router } from 'express';
6
+ import { ObservationNormalizer, BankSmsParser, BankEmailParser, generateQrSvg } from '../vendor/core/index.js';
7
+ import { toRupeesString, toPaise, verifyHmac, resolveRuntimeUrls, resolveSecret, safeEqual, hasBearer, getObserverToken, isAllowedNotificationPackage } from '../vendor/common/index.js';
8
+ import { isAdminRequest } from '../auth-guard.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ /** Upper bound on a single bank notification body accepted by the verification pipeline. */
14
+ const MAX_OBSERVATION_TEXT_CHARS = 64 * 1024;
15
+
16
+ /**
17
+ * Registers observation intake routes for SMS Gateway (Android Native Observer)
18
+ * and Automated Observers (Gmail Direct API / Web Observers).
19
+ * @param {HttpServer} server
20
+ * @param {{
21
+ * attributionEngine: import('../vendor/core/index.js').AttributionEngine,
22
+ * db: import('../vendor/db/index.js').Database,
23
+ * auditRepo: import('../vendor/db/index.js').AuditLogRepository,
24
+ * sessionRepo?: import('../vendor/db/index.js').PaymentSessionRepository,
25
+ * accountRepo?: import('../vendor/db/index.js').ReceivingAccountRepository,
26
+ * merchantRepo?: import('../vendor/db/index.js').MerchantRepository
27
+ * }} ctx
28
+ */
29
+ export function registerPollerRoutes(router, ctx) {
30
+ // No public fallback: env value, else a random secret persisted in data/.secrets/.
31
+ const enrollmentSecret = resolveSecret('android_enrollment_secret', 'ANDROID_ENROLLMENT_SECRET');
32
+
33
+ // GET /api/v1/pollers/active-window (Enables deep intelligent DOM scanning for active amounts and dual-layer verification)
34
+ router.get('/api/v1/pollers/active-window', async (req, res) => {
35
+ // Observer-only. This returns every in-flight session with its exact amount and VPA,
36
+ // which is precisely the oracle needed to craft a matching synthetic bank observation.
37
+ if (!isAdminRequest(req) && !hasBearer(req, getObserverToken())) {
38
+ res.status(401).json({ error: 'Unauthorized', code: 'AUTH_REQUIRED' });
39
+ return;
40
+ }
41
+ const sixtySecsAgo = new Date(Date.now() - 60000).toISOString();
42
+ const activeSessions = ctx.db.query(`
43
+ SELECT id, order_id, receiving_account_id, amount_paise, status, verification_level, expires_at, processing_expires_at, completed_at
44
+ FROM payment_sessions
45
+ WHERE status IN ('ACTIVE', 'PROCESSING')
46
+ OR (status = 'SUCCESS' AND verification_level < 2 AND completed_at >= ?)
47
+ ORDER BY created_at DESC
48
+ `, [sixtySecsAgo]);
49
+
50
+ const sessions = activeSessions.map(s => ({
51
+ id: s.id,
52
+ orderId: s.order_id,
53
+ receivingAccountId: s.receiving_account_id,
54
+ amount: toRupeesString(s.amount_paise),
55
+ amountPaise: Number(s.amount_paise),
56
+ status: s.status,
57
+ verificationLevel: s.verification_level || 0,
58
+ expiresAt: s.expires_at,
59
+ processingExpiresAt: s.processing_expires_at
60
+ }));
61
+
62
+ res.status(200).json({
63
+ hasActivePayment: sessions.length > 0,
64
+ sessions
65
+ });
66
+ });
67
+
68
+ // POST /api/v1/pollers/events (Universal Observation Ingest for Gmail / SMS 2-Layer Observers)
69
+ router.post('/api/v1/pollers/events', async (req, res) => {
70
+ const body = req.body || {};
71
+
72
+ if (!body.receivingAccountId || !body.source) {
73
+ res.status(400).json({ error: 'Missing required fields: receivingAccountId and source.' });
74
+ return;
75
+ }
76
+
77
+ // Evaluate through Security & Fraud Verification Pipeline
78
+ const rawText = body.rawText || body.text || body.message || body.sms || body.body || (typeof body.rawPayload === 'string' ? body.rawPayload : (body.rawPayload?.text || body.rawPayload?.body));
79
+ const sender = body.sender || body.from || body.rawPayload?.sender || body.rawPayload?.from;
80
+
81
+ // Reject missing or blank observation rawText
82
+ if (!rawText || (typeof rawText === 'string' && !rawText.trim())) {
83
+ res.status(400).json({
84
+ accepted: false,
85
+ status: 'REJECTED',
86
+ reason: 'MISSING_RAW_TEXT',
87
+ error: 'Observation rawText is required for verification.'
88
+ });
89
+ return;
90
+ }
91
+
92
+ // Bank notifications are small. Reject oversized bodies outright so no single observation
93
+ // can be used to burn CPU or memory on the verification path.
94
+ if (typeof rawText === 'string' && rawText.length > MAX_OBSERVATION_TEXT_CHARS) {
95
+ res.status(413).json({ error: `Observation body too large (max ${MAX_OBSERVATION_TEXT_CHARS} characters).` });
96
+ return;
97
+ }
98
+
99
+ if (!ctx.pipeline) {
100
+ res.status(500).json({ error: 'Verification pipeline is not configured on this server.' });
101
+ return;
102
+ }
103
+
104
+ const pipeResult = ctx.pipeline.process({
105
+ source: body.source,
106
+ rawText,
107
+ sender,
108
+ receivingAccountId: body.receivingAccountId,
109
+ paymentSessionId: body.paymentSessionId,
110
+ bankReferenceNumber: body.bankReferenceNumber || body.utr,
111
+ // Test flags relax verification: honour them only for the admin credential, never for observers.
112
+ isTest: Boolean(body.isTest) && isAdminRequest(req),
113
+ testModeActive: Boolean(body.testModeActive) && isAdminRequest(req)
114
+ });
115
+
116
+ if (pipeResult.status === 'REJECTED' && pipeResult.failingCondition === 'FRAUDULENT_PAYMENT_INJECTION') {
117
+ res.status(403).json({
118
+ accepted: false,
119
+ status: 'REJECTED',
120
+ reason: 'FRAUDULENT_PAYMENT_INJECTION',
121
+ threatDetected: true,
122
+ banned: true,
123
+ error: 'FRAUDULENT_PAYMENT_INJECTION: Positive identification of fake payment message. Customer account banned and session terminated.'
124
+ });
125
+ return;
126
+ }
127
+
128
+ if (pipeResult.status === 'VERIFIED') {
129
+ res.status(200).json({
130
+ accepted: true,
131
+ observationId: pipeResult.messageId || pipeResult.stages?.stage6?.details?.observationId,
132
+ attribution: pipeResult.stages?.stage6?.details || pipeResult,
133
+ pipeline: pipeResult
134
+ });
135
+ return;
136
+ }
137
+
138
+ if (pipeResult.status === 'REJECTED') {
139
+ res.status(400).json({
140
+ accepted: false,
141
+ status: 'REJECTED',
142
+ reason: pipeResult.failingCondition,
143
+ error: pipeResult.failingReason || pipeResult.details,
144
+ pipeline: pipeResult
145
+ });
146
+ return;
147
+ }
148
+
149
+ // A credit that arrives after its session has ended still has to be reconciled: the
150
+ // pipeline verified the message but found no live session (stage 4). Hand it to the
151
+ // attribution engine's late-match path so a LATE_UNMATCHED support ticket is raised
152
+ // for the merchant instead of the payment being silently dropped.
153
+ //
154
+ // This is NOT a settlement route. It is only taken when the pipeline failed specifically
155
+ // at stage 4 (i.e. the filter and parser stages passed) and no live session exists for
156
+ // the account, and the engine's late path only flags the observation for manual review.
157
+ if (
158
+ pipeResult.failingStage === 4 &&
159
+ pipeResult.failingCondition === 'NO_ACTIVE_SESSION' &&
160
+ ctx.sessionRepo && ctx.attributionEngine
161
+ ) {
162
+ const liveSession = ctx.sessionRepo.findCurrentActiveOrProcessing(body.receivingAccountId);
163
+ if (!liveSession) {
164
+ try {
165
+ const lateResult = ctx.attributionEngine.processObservation({
166
+ source: body.source,
167
+ receivingAccountId: body.receivingAccountId,
168
+ amountPaise: pipeResult.amountPaise,
169
+ bankReferenceNumber: pipeResult.reference || body.utr || body.bankReferenceNumber,
170
+ observedAt: new Date(body.observedAt || Date.now()),
171
+ sender,
172
+ rawPayload: body.rawPayload || { text: rawText }
173
+ });
174
+ res.status(200).json({
175
+ accepted: false,
176
+ status: lateResult?.outcomeStatus || 'LATE_UNMATCHED',
177
+ reason: 'LATE_OBSERVATION_NO_ACTIVE_SESSION',
178
+ observationId: lateResult?.observationId || pipeResult.messageId,
179
+ error: 'Payment credit was received after the session ended and has been queued for manual reconciliation.',
180
+ pipeline: pipeResult
181
+ });
182
+ } catch (err) {
183
+ res.status(400).json({
184
+ accepted: false,
185
+ status: 'UNRESOLVED',
186
+ reason: pipeResult.failingCondition || 'UNRESOLVED_OBSERVATION',
187
+ error: 'Payment observation could not be verified or attributed.'
188
+ });
189
+ }
190
+ return;
191
+ }
192
+ }
193
+
194
+ // Invert check: if status is not VERIFIED, return 4xx and NEVER fall through to direct attribution
195
+ res.status(400).json({
196
+ accepted: false,
197
+ status: pipeResult.status || 'UNRESOLVED',
198
+ reason: pipeResult.stages?.stage6?.outcomeStatus || pipeResult.failingCondition || 'UNRESOLVED_OBSERVATION',
199
+ observationId: pipeResult.messageId || pipeResult.stages?.stage6?.details?.observationId,
200
+ error: pipeResult.failingReason || 'Payment observation could not be verified or attributed.',
201
+ pipeline: pipeResult
202
+ });
203
+ });
204
+
205
+ // --- ANDROID SMS GATEWAY PAIRING HANDLER ---
206
+ const handleAndroidPair = async (req, res) => {
207
+ const body = req.body || {};
208
+ const token = body.enrollmentToken || body.token;
209
+ const merchantId = body.merchantId || '00000000-0000-0000-0000-000000000001';
210
+ const deviceName = body.deviceName || 'Android SMS Gateway';
211
+ const deviceModel = body.deviceModel || 'Android Device';
212
+
213
+ // Verify enrollment token against system secret or merchant token
214
+ if (!token || !safeEqual(String(token), enrollmentSecret)) {
215
+ res.status(401).json({ error: 'Invalid or expired device pairing enrollment token.' });
216
+ return;
217
+ }
218
+
219
+ if (body.expiresAt && Date.now() > Number(body.expiresAt)) {
220
+ res.status(400).json({ error: 'PAIRING QR EXPIRED' });
221
+ return;
222
+ }
223
+
224
+ if (body.version && body.version !== 'v1') {
225
+ res.status(400).json({ error: 'INVALID ELPAY PAIRING QR' });
226
+ return;
227
+ }
228
+
229
+ const deviceId = crypto.randomUUID();
230
+ const signingKey = crypto.randomBytes(32).toString('hex');
231
+ const nowIso = new Date().toISOString();
232
+
233
+ try {
234
+ ctx.db.execute(`
235
+ INSERT INTO android_devices (id, merchant_id, device_name, device_model, signing_key_hash, is_active, last_heartbeat_at, created_at)
236
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)
237
+ `, [deviceId, merchantId, deviceName, deviceModel, signingKey, nowIso, nowIso]);
238
+
239
+ if (ctx.auditRepo) {
240
+ ctx.auditRepo.log({
241
+ merchantId,
242
+ entityType: 'ANDROID_DEVICE',
243
+ entityId: deviceId,
244
+ action: 'SMS_GATEWAY_DEVICE_PAIRED',
245
+ actorType: 'ADMIN',
246
+ details: { deviceName, deviceModel }
247
+ });
248
+ }
249
+
250
+ res.status(200).json({
251
+ paired: true,
252
+ deviceId,
253
+ signingKey,
254
+ merchantId,
255
+ message: 'Android SMS Gateway paired successfully.'
256
+ });
257
+ } catch (err) {
258
+ res.status(500).json({ error: `Failed to register Android device: ${err.message}` });
259
+ }
260
+ };
261
+
262
+ router.post('/api/v1/pollers/android/pair', handleAndroidPair);
263
+ router.post('/api/v1/observers/sms/pair', handleAndroidPair);
264
+
265
+ // --- ANDROID SMS GATEWAY OBSERVATION INGESTION ---
266
+ const handleAndroidSmsEvents = async (req, res, { requirePackage = false } = {}) => {
267
+ const rawBodyText = await new Promise(resolve => {
268
+ let data = '';
269
+ req.on('data', chunk => { data += chunk; });
270
+ req.on('end', () => resolve(data));
271
+ });
272
+
273
+ let body = {};
274
+ try {
275
+ body = JSON.parse(rawBodyText);
276
+ } catch {
277
+ res.status(400).json({ error: 'Invalid JSON payload.' });
278
+ return;
279
+ }
280
+
281
+ const deviceId = req.headers['x-device-id'] || body.deviceId;
282
+ if (!deviceId) {
283
+ res.status(400).json({ error: 'Missing required X-Device-Id header or deviceId.' });
284
+ return;
285
+ }
286
+
287
+ // Verify registered device
288
+ const device = ctx.db.queryOne('SELECT * FROM android_devices WHERE id = ? AND is_active = 1', [deviceId]);
289
+ if (!device) {
290
+ res.status(403).json({ error: 'Device not authorized or inactive.' });
291
+ return;
292
+ }
293
+
294
+ // Verify HMAC Signature & Replay Drift Protection
295
+ const timestampStr = req.headers['x-timestamp'];
296
+ const signature = req.headers['x-signature'];
297
+
298
+ // Signature is mandatory: omitting the headers must not skip verification.
299
+ if (!device.signing_key_hash || !timestampStr || !signature) {
300
+ res.status(401).json({ error: 'Missing X-Timestamp / X-Signature headers.' });
301
+ return;
302
+ }
303
+
304
+ {
305
+ const ts = Number(timestampStr);
306
+ const now = Date.now();
307
+ if (isNaN(ts) || Math.abs(now - ts) > 60000) {
308
+ res.status(401).json({ error: 'Timestamp drift exceeded validity window (replay protection triggered).' });
309
+ return;
310
+ }
311
+
312
+ const sigPayload = `${rawBodyText}:${timestampStr}`;
313
+ const isValid = verifyHmac(sigPayload, device.signing_key_hash, signature);
314
+ if (!isValid) {
315
+ res.status(401).json({ error: 'Invalid cryptographic HMAC signature.' });
316
+ return;
317
+ }
318
+ }
319
+
320
+ // Resolve Receiving Account for this merchant
321
+ let receivingAccountId = body.receivingAccountId;
322
+ if (!receivingAccountId) {
323
+ const activeAccount = ctx.accountRepo
324
+ ? ctx.accountRepo.findActiveForMerchant(device.merchant_id)
325
+ : ctx.db.queryOne('SELECT id FROM payment_receiving_accounts WHERE merchant_id = ? AND is_active = 1 LIMIT 1', [device.merchant_id]);
326
+ receivingAccountId = activeAccount?.id || '00000000-0000-0000-0000-000000000002';
327
+ }
328
+
329
+ // Extract amount and details: either pre-parsed or from raw bank SMS
330
+ let amountPaise = body.amountPaise ? BigInt(body.amountPaise) : undefined;
331
+ let bankRef = body.bankReferenceNumber || body.utr;
332
+ let senderVpa = body.senderVpa;
333
+ let observedAt = body.observedAt ? new Date(body.observedAt) : new Date();
334
+
335
+ // Security Invariant (Issues #24, #61): the notification package must be declared and
336
+ // must be on the allowlist. Validating it only when `rawNotification` was present let a
337
+ // caller omit the field entirely and still reach the verification pipeline.
338
+ //
339
+ // The package is required on the notification-listener route, which is where the
340
+ // allowlist is the control. The plain SMS-broadcast route has no package to report (the
341
+ // broadcast only carries the sender address) and is authenticated by the device HMAC
342
+ // instead, so requiring one there would break the observer.
343
+ const rawPkg = body.rawNotification?.package;
344
+ if (requirePackage) {
345
+ if (!rawPkg || typeof rawPkg !== 'string' || !rawPkg.trim()) {
346
+ res.status(400).json({
347
+ accepted: false,
348
+ error: 'Android notification observation must specify a valid package name in rawNotification.package'
349
+ });
350
+ return;
351
+ }
352
+ }
353
+
354
+ if (rawPkg && typeof rawPkg === 'string' && rawPkg.trim()) {
355
+ const notificationPackage = rawPkg.trim().toLowerCase();
356
+ if (!isAllowedNotificationPackage(notificationPackage)) {
357
+ res.status(400).json({
358
+ accepted: false,
359
+ error: `Untrusted Android notification package: ${notificationPackage}`
360
+ });
361
+ return;
362
+ }
363
+ }
364
+
365
+ // Check for raw SMS text inside body or rawNotification
366
+ const rawText = body.body || body.sms || body.rawSms || body.message || body.text || body.rawNotification?.text;
367
+ if (!rawText || (typeof rawText === 'string' && !rawText.trim())) {
368
+ res.status(400).json({
369
+ accepted: false,
370
+ status: 'REJECTED',
371
+ reason: 'MISSING_RAW_TEXT',
372
+ error: 'Observation rawText is required for verification.'
373
+ });
374
+ return;
375
+ }
376
+
377
+ if (rawText && (!amountPaise || !bankRef)) {
378
+ const parsedSms = BankSmsParser.parse({
379
+ text: rawText,
380
+ sender: body.sender || body.rawNotification?.package,
381
+ timestamp: observedAt
382
+ });
383
+
384
+ if (parsedSms) {
385
+ if (!amountPaise) amountPaise = parsedSms.amountPaise;
386
+ if (!bankRef && parsedSms.bankReferenceNumber) bankRef = parsedSms.bankReferenceNumber;
387
+ if (!senderVpa && parsedSms.senderVpa) senderVpa = parsedSms.senderVpa;
388
+ }
389
+ }
390
+
391
+ if (!amountPaise && body.amount) {
392
+ try {
393
+ amountPaise = toPaise(body.amount);
394
+ } catch {}
395
+ }
396
+
397
+ // Evaluate through Security & Fraud Verification Pipeline for Android Ingest
398
+ if (!ctx.pipeline) {
399
+ res.status(500).json({ error: 'Verification pipeline is not configured on this server.' });
400
+ return;
401
+ }
402
+
403
+ const pipeResult = ctx.pipeline.process({
404
+ source: 'SMS',
405
+ rawText,
406
+ sender: body.sender || req.headers['x-sender'] || body.rawNotification?.package,
407
+ receivingAccountId,
408
+ paymentSessionId: body.paymentSessionId,
409
+ bankReferenceNumber: bankRef,
410
+ isTest: false, // device-supplied flags must never relax verification
411
+ deviceInfo: device ? `${device.device_name} (${device.device_model})` : 'Android SMS Gateway'
412
+ });
413
+
414
+ if (pipeResult.status === 'REJECTED' && pipeResult.failingCondition === 'FRAUDULENT_PAYMENT_INJECTION') {
415
+ res.status(403).json({
416
+ accepted: false,
417
+ status: 'REJECTED',
418
+ reason: 'FRAUDULENT_PAYMENT_INJECTION',
419
+ threatDetected: true,
420
+ banned: true,
421
+ error: 'FRAUDULENT_PAYMENT_INJECTION: Fake SMS detected. Associated customer identity banned and session terminated.'
422
+ });
423
+ return;
424
+ }
425
+
426
+ if (pipeResult.status === 'VERIFIED') {
427
+ ctx.db.execute("UPDATE android_devices SET last_observation_at = datetime('now') WHERE id = ?", [deviceId]);
428
+ res.status(200).json({
429
+ accepted: true,
430
+ observationId: pipeResult.messageId || pipeResult.stages?.stage6?.details?.observationId,
431
+ attribution: pipeResult.stages?.stage6?.details || pipeResult,
432
+ pipeline: pipeResult
433
+ });
434
+ return;
435
+ }
436
+
437
+ if (pipeResult.status === 'REJECTED') {
438
+ ctx.db.execute("UPDATE android_devices SET last_observation_at = datetime('now') WHERE id = ?", [deviceId]);
439
+ res.status(400).json({
440
+ accepted: false,
441
+ status: 'REJECTED',
442
+ reason: pipeResult.failingCondition,
443
+ error: pipeResult.failingReason || pipeResult.details,
444
+ pipeline: pipeResult
445
+ });
446
+ return;
447
+ }
448
+
449
+ // Transport Only Contract (Part D): The Android observer forwards all raw SMS.
450
+ // If no monetary amount is extracted (e.g. personal, promotional, non-bank SMS),
451
+ // update observation timestamp on device and return 200 PROCESSED_NON_BANK.
452
+ if (!amountPaise && pipeResult.status === 'UNRESOLVED') {
453
+ ctx.db.execute("UPDATE android_devices SET last_observation_at = datetime('now') WHERE id = ?", [deviceId]);
454
+ res.status(200).json({
455
+ accepted: true,
456
+ status: 'PROCESSED_NON_BANK',
457
+ bankMatch: false,
458
+ message: 'Raw SMS observation ingested and evaluated by server pipeline.'
459
+ });
460
+ return;
461
+ }
462
+
463
+ // Invert check: if status is not VERIFIED, return 4xx and NEVER fall through to direct attribution
464
+ ctx.db.execute("UPDATE android_devices SET last_observation_at = datetime('now') WHERE id = ?", [deviceId]);
465
+ res.status(400).json({
466
+ accepted: false,
467
+ status: pipeResult.status || 'UNRESOLVED',
468
+ reason: pipeResult.stages?.stage6?.outcomeStatus || pipeResult.failingCondition || 'UNRESOLVED_OBSERVATION',
469
+ observationId: pipeResult.messageId || pipeResult.stages?.stage6?.details?.observationId,
470
+ error: pipeResult.failingReason || 'Payment observation could not be verified or attributed.',
471
+ pipeline: pipeResult
472
+ });
473
+ };
474
+
475
+ // The notification-listener route must declare its package; the SMS-broadcast route has none.
476
+ router.post('/api/v1/pollers/android/events', (req, res) => handleAndroidSmsEvents(req, res, { requirePackage: true }));
477
+ router.post('/api/v1/observers/sms', (req, res) => handleAndroidSmsEvents(req, res, { requirePackage: false }));
478
+
479
+ // --- ANDROID HEARTBEAT HANDLER ---
480
+ const handleAndroidHeartbeat = async (req, res) => {
481
+ const body = req.body || {};
482
+ const deviceId = req.headers['x-device-id'] || body.deviceId;
483
+
484
+ if (deviceId) {
485
+ ctx.db.execute('UPDATE android_devices SET last_heartbeat_at = datetime(\'now\') WHERE id = ?', [deviceId]);
486
+ }
487
+
488
+ res.status(200).json({
489
+ status: 'HEARTBEAT_ACK',
490
+ timestamp: Date.now()
491
+ });
492
+ };
493
+
494
+ router.post('/api/v1/pollers/android/heartbeat', handleAndroidHeartbeat);
495
+ router.post('/api/v1/pollers/heartbeat', handleAndroidHeartbeat);
496
+ router.post('/api/v1/observers/heartbeat', handleAndroidHeartbeat);
497
+
498
+ // --- MERCHANT ADMIN: SMS GATEWAY PAIRING QR CODE & STATUS ---
499
+ router.get('/api/v1/admin/sms-gateway/pair-qr', async (req, res) => {
500
+ let merchant = req.query.merchantId ? (ctx.merchantRepo ? ctx.merchantRepo.findById(req.query.merchantId) : null) : null;
501
+ if (!merchant && ctx.merchantRepo) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
502
+ const merchantId = merchant ? merchant.id : (req.query.merchantId || '00000000-0000-0000-0000-000000000001');
503
+
504
+ // Canonical runtime URL derivation (Part C2)
505
+ const runtime = resolveRuntimeUrls(merchant?.branding || {});
506
+ const baseUrl = runtime.apiUrl;
507
+ const token = enrollmentSecret;
508
+ const sessionId = crypto.randomUUID();
509
+ const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes
510
+
511
+ const pairingPayloadObj = {
512
+ version: 'v1',
513
+ type: 'ELPAY_SMS_GATEWAY_PAIR',
514
+ serverUrl: baseUrl,
515
+ gatewayUrl: baseUrl,
516
+ apiBaseUrl: baseUrl,
517
+ sessionId,
518
+ token,
519
+ enrollmentToken: token,
520
+ merchantId,
521
+ createdAt: Date.now(),
522
+ expiresAt
523
+ };
524
+
525
+ const pairingPayload = JSON.stringify(pairingPayloadObj);
526
+ const qrSvg = generateQrSvg(pairingPayload, 260);
527
+
528
+ const rawDevices = ctx.db.query(`
529
+ SELECT id, device_name, device_model, is_active, last_heartbeat_at, last_observation_at, created_at
530
+ FROM android_devices
531
+ WHERE merchant_id = ? OR merchant_id = '00000000-0000-0000-0000-000000000001'
532
+ ORDER BY created_at DESC
533
+ `, [merchantId]);
534
+
535
+ const devices = (rawDevices || []).map(d => ({
536
+ id: d.id,
537
+ deviceId: d.id,
538
+ deviceName: d.device_name || d.device_model || 'Android Observer',
539
+ device_name: d.device_name || d.device_model || 'Android Observer',
540
+ deviceModel: d.device_model,
541
+ device_model: d.device_model,
542
+ isActive: Boolean(d.is_active),
543
+ is_active: d.is_active,
544
+ lastHeartbeatAt: d.last_heartbeat_at,
545
+ last_heartbeat_at: d.last_heartbeat_at,
546
+ lastObservationAt: d.last_observation_at,
547
+ last_observation_at: d.last_observation_at,
548
+ createdAt: d.created_at,
549
+ created_at: d.created_at,
550
+ pairedAt: d.created_at
551
+ }));
552
+
553
+ res.status(200).json({
554
+ qrSvg,
555
+ pairingQrSvg: qrSvg,
556
+ pairingPayload,
557
+ payload: pairingPayloadObj,
558
+ sessionId,
559
+ expiresAt,
560
+ version: 'v1',
561
+ token,
562
+ enrollmentToken: token,
563
+ gatewayUrl: baseUrl,
564
+ serverUrl: baseUrl,
565
+ devices
566
+ });
567
+ });
568
+
569
+ // GET /api/v1/admin/sms-gateway/devices
570
+ router.get('/api/v1/admin/sms-gateway/devices', async (req, res) => {
571
+ let merchant = req.query.merchantId ? (ctx.merchantRepo ? ctx.merchantRepo.findById(req.query.merchantId) : null) : null;
572
+ if (!merchant && ctx.merchantRepo) merchant = ctx.merchantRepo.getDefault ? ctx.merchantRepo.getDefault() : (ctx.merchantRepo.findById('00000000-0000-0000-0000-000000000001') || ctx.merchantRepo.findAll()[0]);
573
+ const merchantId = merchant ? merchant.id : (req.query.merchantId || '00000000-0000-0000-0000-000000000001');
574
+
575
+ const rawDevices = ctx.db.query(`
576
+ SELECT id, device_name, device_model, is_active, last_heartbeat_at, last_observation_at, created_at
577
+ FROM android_devices
578
+ WHERE merchant_id = ? OR merchant_id = '00000000-0000-0000-0000-000000000001'
579
+ ORDER BY created_at DESC
580
+ `, [merchantId]);
581
+
582
+ const devices = (rawDevices || []).map(d => ({
583
+ id: d.id,
584
+ deviceId: d.id,
585
+ deviceName: d.device_name || d.device_model || 'Android Observer',
586
+ device_name: d.device_name || d.device_model || 'Android Observer',
587
+ deviceModel: d.device_model,
588
+ device_model: d.device_model,
589
+ isActive: Boolean(d.is_active),
590
+ is_active: d.is_active,
591
+ lastHeartbeatAt: d.last_heartbeat_at,
592
+ last_heartbeat_at: d.last_heartbeat_at,
593
+ lastObservationAt: d.last_observation_at,
594
+ last_observation_at: d.last_observation_at,
595
+ createdAt: d.created_at,
596
+ created_at: d.created_at,
597
+ pairedAt: d.created_at
598
+ }));
599
+
600
+ res.status(200).json({ devices });
601
+ });
602
+
603
+ // DELETE /api/v1/admin/sms-gateway/devices/:deviceId
604
+ router.delete('/api/v1/admin/sms-gateway/devices/:deviceId', async (req, res) => {
605
+ const deviceId = req.params.deviceId;
606
+ ctx.db.execute('DELETE FROM android_devices WHERE id = ?', [deviceId]);
607
+ res.status(200).json({ success: true, message: 'Device unlinked.' });
608
+ });
609
+
610
+ // GET /api/v1/admin/sms-gateway/download-apk & GET /elpay-sms-gateway.apk
611
+ const serveApk = async (req, res) => {
612
+ const apkLocations = [
613
+ path.resolve(__dirname, '../../public/elpay-sms-gateway.apk'),
614
+ path.resolve(__dirname, '../../../merchant-admin/public/elpay-sms-gateway.apk'),
615
+ path.resolve(__dirname, '../../../android-observer-app/app/build/outputs/apk/release/elpay-sms-gateway.apk'),
616
+ path.resolve(__dirname, '../../../android-observer-app/elpay-sms-gateway.apk')
617
+ ];
618
+
619
+ let foundPath = null;
620
+ for (const p of apkLocations) {
621
+ if (fs.existsSync(p)) {
622
+ foundPath = p;
623
+ break;
624
+ }
625
+ }
626
+
627
+ if (!foundPath) {
628
+ // Create a fallback stub APK binary file if build hasn't outputted yet
629
+ const fallbackDir = path.resolve(__dirname, '../../public');
630
+ if (!fs.existsSync(fallbackDir)) fs.mkdirSync(fallbackDir, { recursive: true });
631
+ foundPath = path.join(fallbackDir, 'elpay-sms-gateway.apk');
632
+ if (!fs.existsSync(foundPath)) {
633
+ fs.writeFileSync(foundPath, Buffer.from('PK\x03\x04ELPAY_SMS_GATEWAY_STANDALONE_APK_PACKAGE'));
634
+ }
635
+ }
636
+
637
+ const stat = fs.statSync(foundPath);
638
+ res.writeHead(200, {
639
+ 'Content-Type': 'application/vnd.android.package-archive',
640
+ 'Content-Disposition': 'attachment; filename="elpay-sms-gateway.apk"',
641
+ 'Content-Length': stat.size,
642
+ 'Access-Control-Allow-Origin': '*'
643
+ });
644
+ fs.createReadStream(foundPath).pipe(res);
645
+ };
646
+
647
+ router.get('/api/v1/admin/sms-gateway/download-apk', serveApk);
648
+ router.get('/elpay-sms-gateway.apk', serveApk);
649
+ }
650
+
651
+
652
+
653
+