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,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
|
+
|