elpaypg 0.0.0-stage → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.env.example +8 -0
  2. package/README.md +176 -2
  3. package/package.json +21 -4
  4. package/public/elpay-checkout.js +769 -0
  5. package/public/elpay-sms-gateway.apk +0 -0
  6. package/public/elpay-sms-gateway.apk.sha256 +1 -0
  7. package/public/elpay.js +785 -0
  8. package/public/fonts/dm-sans.woff2 +0 -0
  9. package/public/fonts/sora.woff2 +0 -0
  10. package/public/views/checkout.html +2839 -0
  11. package/public/views/partials/auth.html +110 -0
  12. package/public/views/partials/footer.html +54 -0
  13. package/public/views/partials/header.html +101 -0
  14. package/public/views/partials/modals.html +179 -0
  15. package/public/views/partials/payment-methods.html +526 -0
  16. package/public/views/partials/sidebar.html +75 -0
  17. package/public/views/partials/splash.html +35 -0
  18. package/src/__tests__/smoke.test.js +8 -0
  19. package/src/auth-guard.js +95 -0
  20. package/src/index.js +137 -0
  21. package/src/router.js +46 -0
  22. package/src/routes/admin.routes.js +2424 -0
  23. package/src/routes/checkout.routes.js +2000 -0
  24. package/src/routes/customer.routes.js +650 -0
  25. package/src/routes/device.routes.js +303 -0
  26. package/src/routes/payments.routes.js +446 -0
  27. package/src/routes/pollers.routes.js +653 -0
  28. package/src/services/observer-poller.service.js +369 -0
  29. package/src/services/tunnel-manager.js +130 -0
  30. package/src/services/whatsapp-berry.service.js +743 -0
  31. package/src/vendor/common/__tests__/common.test.js +297 -0
  32. package/src/vendor/common/__tests__/common.test.ts +160 -0
  33. package/src/vendor/common/contracts.js +64 -0
  34. package/src/vendor/common/contracts.ts +188 -0
  35. package/src/vendor/common/crypto.js +132 -0
  36. package/src/vendor/common/crypto.ts +112 -0
  37. package/src/vendor/common/enums.js +91 -0
  38. package/src/vendor/common/enums.ts +91 -0
  39. package/src/vendor/common/index.js +12 -0
  40. package/src/vendor/common/index.ts +8 -0
  41. package/src/vendor/common/money.js +62 -0
  42. package/src/vendor/common/money.ts +56 -0
  43. package/src/vendor/common/observation.js +27 -0
  44. package/src/vendor/common/observation.ts +52 -0
  45. package/src/vendor/common/runtime-config.js +246 -0
  46. package/src/vendor/common/secrets.js +374 -0
  47. package/src/vendor/common/state-machine.d.ts +1 -0
  48. package/src/vendor/common/state-machine.js +1 -0
  49. package/src/vendor/common/state-transitions.js +85 -0
  50. package/src/vendor/common/tunnel-command.js +59 -0
  51. package/src/vendor/common/upi-providers.js +389 -0
  52. package/src/vendor/common/upi-providers.ts +400 -0
  53. package/src/vendor/common/upi.js +69 -0
  54. package/src/vendor/common/upi.ts +61 -0
  55. package/src/vendor/core/__tests__/core.test.js +683 -0
  56. package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
  57. package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
  58. package/src/vendor/core/attribution-engine.js +855 -0
  59. package/src/vendor/core/bank-email-parser.js +105 -0
  60. package/src/vendor/core/bank-sms-parser.js +183 -0
  61. package/src/vendor/core/device-security.js +506 -0
  62. package/src/vendor/core/index.js +13 -0
  63. package/src/vendor/core/normalizer.js +165 -0
  64. package/src/vendor/core/qr-lib.js +8 -0
  65. package/src/vendor/core/qr.js +62 -0
  66. package/src/vendor/core/queue-engine.js +153 -0
  67. package/src/vendor/core/recovery-engine.js +159 -0
  68. package/src/vendor/core/state-machine.js +42 -0
  69. package/src/vendor/core/timing-engine.js +272 -0
  70. package/src/vendor/core/verification-pipeline.js +1164 -0
  71. package/src/vendor/core/webhook-engine.js +280 -0
  72. package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
  73. package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
  74. package/src/vendor/db/__tests__/db.test.js +265 -0
  75. package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
  76. package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
  77. package/src/vendor/db/database.js +181 -0
  78. package/src/vendor/db/index.js +12 -0
  79. package/src/vendor/db/migrate.js +210 -0
  80. package/src/vendor/db/repositories/audit-log.repository.js +68 -0
  81. package/src/vendor/db/repositories/customer-session.repository.js +181 -0
  82. package/src/vendor/db/repositories/customer.repository.js +636 -0
  83. package/src/vendor/db/repositories/device.repository.js +285 -0
  84. package/src/vendor/db/repositories/merchant.repository.js +158 -0
  85. package/src/vendor/db/repositories/observation.repository.js +124 -0
  86. package/src/vendor/db/repositories/payment-session.repository.js +618 -0
  87. package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
  88. package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
  89. package/src/vendor/db/schema.sql +266 -0
  90. package/src/vendor/db/sqlite-schema.sql +270 -0
@@ -0,0 +1,650 @@
1
+ import crypto from 'node:crypto';
2
+ import { Router } from 'express';
3
+ import { normalizeEmail, normalizeMobile, isValidEmail, isValidMobile, isValidPassword, safeEqual } from '../vendor/common/index.js';
4
+
5
+ /** Wrong guesses tolerated per issued OTP challenge. */
6
+ const OTP_MAX_ATTEMPTS = 5;
7
+ /** Minimum gap between OTP requests for the same mobile number. */
8
+ const OTP_RESEND_COOLDOWN_MS = 30 * 1000;
9
+
10
+ /**
11
+ * Safely masks customer email address for public responses without leaking length or erroring on short strings.
12
+ * @param {string} email
13
+ * @returns {string | null}
14
+ */
15
+ function maskEmail(email) {
16
+ if (!email || typeof email !== 'string') return null;
17
+ const atIndex = email.indexOf('@');
18
+ if (atIndex <= 0) return '***';
19
+ const user = email.slice(0, atIndex);
20
+ const domain = email.slice(atIndex + 1);
21
+ const visible = user.slice(0, Math.min(2, user.length));
22
+ return `${visible}***@${domain || '***'}`;
23
+ }
24
+
25
+ /**
26
+ * Registers gateway-owned customer identity and session routes.
27
+ * @param {HttpServer} server
28
+ * @param {{
29
+ * customerRepo: import('../vendor/db/index.js').CustomerRepository,
30
+ * customerSessionRepo: import('../vendor/db/index.js').CustomerSessionRepository,
31
+ * sessionRepo: import('../vendor/db/index.js').PaymentSessionRepository,
32
+ * auditRepo: import('../vendor/db/index.js').AuditLogRepository
33
+ * }} ctx
34
+ */
35
+ export function registerCustomerRoutes(router, ctx) {
36
+ // Helper to extract session token from Authorization header or Cookie
37
+ function extractSessionToken(req) {
38
+ const authHeader = req.headers['authorization'] || '';
39
+ if (authHeader.startsWith('Bearer ')) {
40
+ return authHeader.slice(7).trim();
41
+ }
42
+ const cookieHeader = req.headers['cookie'] || '';
43
+ const match = cookieHeader.match(/elpay_session=([^;]+)/);
44
+ if (match) {
45
+ return match[1].trim();
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // Helper to set session cookie header
51
+ function buildSessionCookie(token, maxAgeSeconds = 30 * 24 * 3600) {
52
+ const isProd = process.env.NODE_ENV === 'production';
53
+ return `elpay_session=${token}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax${isProd ? '; Secure' : ''}`;
54
+ }
55
+
56
+ // Pending signup challenges cache: mobileNumber -> { otpCode, expiresAt, name, email }
57
+ const pendingOtpChallenges = new Map();
58
+
59
+ // Throttles OTP dispatch per mobile number so the WhatsApp channel cannot be flooded/cost-abused.
60
+ const otpRequestLog = new Map();
61
+
62
+ // Throttles check-user inquiries to prevent bulk mobile number scanning / user enumeration.
63
+ const checkUserRateMap = new Map();
64
+ const checkUserRateCleaner = setInterval(() => {
65
+ const now = Date.now();
66
+ for (const [ip, timestamps] of checkUserRateMap) {
67
+ const active = Array.isArray(timestamps) ? timestamps.filter(t => now - t < 60000) : [];
68
+ if (active.length === 0) {
69
+ checkUserRateMap.delete(ip);
70
+ } else {
71
+ checkUserRateMap.set(ip, active);
72
+ }
73
+ }
74
+ }, 60000);
75
+ if (checkUserRateCleaner.unref) checkUserRateCleaner.unref();
76
+
77
+ /**
78
+ * Resolves the configured cooldown between consecutive OTP dispatches.
79
+ * Prioritizes explicit runtime options (ctx.otpCooldownMs), environment overrides
80
+ * (OTP_RESEND_COOLDOWN_MS), and defaults to 30 seconds.
81
+ */
82
+ function getOtpCooldownMs() {
83
+ if (ctx && typeof ctx.otpCooldownMs === 'number' && ctx.otpCooldownMs >= 0) {
84
+ return ctx.otpCooldownMs;
85
+ }
86
+ if (process.env.OTP_RESEND_COOLDOWN_MS !== undefined) {
87
+ const val = Number(process.env.OTP_RESEND_COOLDOWN_MS);
88
+ if (Number.isFinite(val) && val >= 0) {
89
+ return val;
90
+ }
91
+ }
92
+ return OTP_RESEND_COOLDOWN_MS;
93
+ }
94
+
95
+ /**
96
+ * Records an OTP request and reports whether the caller is within the resend cooldown.
97
+ * Entries older than the cooldown are pruned on each call.
98
+ */
99
+ function isOtpThrottled(mobile) {
100
+ const cooldownMs = getOtpCooldownMs();
101
+ if (cooldownMs === 0) {
102
+ return false;
103
+ }
104
+ const now = Date.now();
105
+ for (const [key, at] of otpRequestLog) {
106
+ if (now - at > cooldownMs) otpRequestLog.delete(key);
107
+ }
108
+ if (otpRequestLog.has(mobile)) return true;
109
+ otpRequestLog.set(mobile, now);
110
+ return false;
111
+ }
112
+
113
+ // Expose reset helper for tests to reset rate-limiting state deterministically
114
+ if (ctx) {
115
+ ctx.resetOtpCooldown = (mobile) => {
116
+ if (mobile) {
117
+ otpRequestLog.delete(normalizeMobile(mobile) || mobile);
118
+ } else {
119
+ otpRequestLog.clear();
120
+ }
121
+ };
122
+ }
123
+
124
+ // Helper to get Berry Service
125
+ function getBerry() {
126
+ return ctx.whatsappBerryService || null;
127
+ }
128
+
129
+ // 1. POST /api/v1/customer/check-user
130
+ // Check whether a customer exists by mobile number
131
+ router.post('/api/v1/customer/check-user', async (req, res) => {
132
+ const body = req.body || {};
133
+ const rawMobile = body.mobileNumber || body.phoneNumber || body.mobile || body.phone;
134
+
135
+ if (!rawMobile) {
136
+ res.status(400).json({ error: 'Mobile number is required.' });
137
+ return;
138
+ }
139
+
140
+ const cleanMobile = normalizeMobile(rawMobile);
141
+ if (!cleanMobile || !isValidMobile(cleanMobile)) {
142
+ res.status(400).json({ error: 'Please enter a valid 10-digit mobile number.' });
143
+ return;
144
+ }
145
+
146
+ const ip = req.socket?.remoteAddress || '127.0.0.1';
147
+ const now = Date.now();
148
+ if (checkUserRateMap.size > 10000) {
149
+ checkUserRateMap.clear();
150
+ }
151
+ const timestamps = checkUserRateMap.get(ip) || [];
152
+ const recent = timestamps.filter(t => now - t < 60000);
153
+ if (recent.length >= 60) {
154
+ res.status(429).json({ error: 'Too many requests. Please try again in a minute.' });
155
+ return;
156
+ }
157
+ recent.push(now);
158
+ checkUserRateMap.set(ip, recent);
159
+
160
+ const existingUser = ctx.customerRepo.findByMobile(cleanMobile);
161
+
162
+ res.status(200).json({
163
+ success: true,
164
+ exists: Boolean(existingUser),
165
+ mobileNumber: cleanMobile,
166
+ customer: existingUser ? {
167
+ id: existingUser.id,
168
+ name: existingUser.name,
169
+ email: maskEmail(existingUser.email),
170
+ mobileNumber: cleanMobile
171
+ } : null
172
+ });
173
+ });
174
+
175
+ // 2. POST /api/v1/customer/send-otp
176
+ // Dispatches 6-digit verification code to customer's WhatsApp via Berry Protocol
177
+ router.post('/api/v1/customer/send-otp', async (req, res) => {
178
+ const body = req.body || {};
179
+ const rawMobile = body.mobileNumber || body.phoneNumber || body.mobile || body.phone;
180
+ const name = (body.name || body.fullName || '').trim();
181
+ const email = normalizeEmail(body.email);
182
+ const isNewUser = Boolean(body.isNewUser);
183
+
184
+ if (!rawMobile) {
185
+ res.status(400).json({ error: 'Mobile number is required.' });
186
+ return;
187
+ }
188
+
189
+ const cleanMobile = normalizeMobile(rawMobile);
190
+ if (!cleanMobile || !isValidMobile(cleanMobile)) {
191
+ res.status(400).json({ error: 'Please enter a valid 10-digit mobile number.' });
192
+ return;
193
+ }
194
+
195
+ const existingUser = ctx.customerRepo.findByMobile(cleanMobile);
196
+
197
+ // Throttle before doing any work: otherwise this endpoint can be used to flood a
198
+ // customer's WhatsApp with OTPs (harassment) or burn merchant messaging credits.
199
+ if (isOtpThrottled(cleanMobile)) {
200
+ res.status(429).json({ error: `Please wait ${Math.ceil(OTP_RESEND_COOLDOWN_MS / 1000)} seconds before requesting another code.` });
201
+ return;
202
+ }
203
+
204
+ // If new user signing up, validate name and email
205
+ if (!existingUser && isNewUser) {
206
+ if (!name) {
207
+ res.status(400).json({ error: 'Full name is required for registration.' });
208
+ return;
209
+ }
210
+ if (!email || !isValidEmail(email)) {
211
+ res.status(400).json({ error: 'A valid email address is required for registration.' });
212
+ return;
213
+ }
214
+ // Check unique email
215
+ const existingByEmail = ctx.customerRepo.findByEmail(email);
216
+ if (existingByEmail) {
217
+ res.status(409).json({ error: 'An account with this email address already exists. Please log in.' });
218
+ return;
219
+ }
220
+ }
221
+
222
+ // Generate 6-digit cryptographic OTP
223
+ const otpCode = crypto.randomInt(100000, 1000000).toString();
224
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
225
+
226
+ if (existingUser) {
227
+ ctx.customerRepo.setOtp(cleanMobile, otpCode, expiresAt);
228
+ } else {
229
+ pendingOtpChallenges.set(cleanMobile, {
230
+ otpCode,
231
+ expiresAt,
232
+ attempts: 0,
233
+ name: name || `Customer ${cleanMobile.slice(-4)}`,
234
+ email: email || `${cleanMobile}@customer.elpay.local`
235
+ });
236
+ }
237
+
238
+ // Dispatch via WhatsApp Berry Protocol
239
+ const berry = getBerry();
240
+ let dispatchResult = { success: true, delivered: false, simulated: true };
241
+ const displayName = existingUser?.name || name || '';
242
+
243
+ if (berry) {
244
+ try {
245
+ dispatchResult = await berry.sendOtp(cleanMobile, otpCode, displayName);
246
+ } catch (err) {
247
+ console.warn(`[CustomerAuth] WhatsApp dispatch notice: ${err.message}`);
248
+ }
249
+ } else {
250
+ console.log(`[CustomerAuth] Berry service not loaded; OTP for ${cleanMobile} was not delivered.`);
251
+ }
252
+
253
+ console.log(`[CustomerAuth] OTP generated for +91${cleanMobile} (expires in 5 mins)`);
254
+
255
+ res.status(200).json({
256
+ success: true,
257
+ message: 'OTP sent to your WhatsApp number.',
258
+ mobileNumber: cleanMobile,
259
+ expiresInSeconds: 300,
260
+ // Returning the OTP lets anyone log in as any number: only on explicit opt-in, never in production.
261
+ devOtp: (process.env.ELPAY_EXPOSE_DEV_OTP === 'true' && process.env.NODE_ENV !== 'production' ? otpCode : undefined),
262
+ dispatch: dispatchResult
263
+ });
264
+ });
265
+
266
+ // 3. POST /api/v1/customer/verify-otp
267
+ // Verifies 6-digit WhatsApp OTP, registers new user if needed, and logs them in
268
+ router.post('/api/v1/customer/verify-otp', async (req, res) => {
269
+ const body = req.body || {};
270
+ const rawMobile = body.mobileNumber || body.phoneNumber || body.mobile || body.phone;
271
+ const otp = String(body.otp || body.code || '').trim();
272
+
273
+ if (!rawMobile || !otp) {
274
+ res.status(400).json({ error: 'Mobile number and 6-digit OTP code are required.' });
275
+ return;
276
+ }
277
+
278
+ const cleanMobile = normalizeMobile(rawMobile);
279
+ if (!cleanMobile) {
280
+ res.status(400).json({ error: 'Invalid mobile number.' });
281
+ return;
282
+ }
283
+
284
+ let customer = ctx.customerRepo.findByMobile(cleanMobile);
285
+
286
+ if (customer) {
287
+ // Existing User Verification
288
+ const verified = ctx.customerRepo.verifyOtp(cleanMobile, otp);
289
+ if (!verified) {
290
+ res.status(400).json({ error: 'Invalid or expired verification code. Please request a new OTP.' });
291
+ return;
292
+ }
293
+ customer = verified;
294
+ } else {
295
+ // New User Registration Verification
296
+ const pending = pendingOtpChallenges.get(cleanMobile);
297
+ if (!pending) {
298
+ res.status(400).json({ error: 'No active OTP request found. Please request a new OTP.' });
299
+ return;
300
+ }
301
+ // Budget the guesses: a 6-digit code is brute-forceable within its window otherwise.
302
+ if ((pending.attempts || 0) >= OTP_MAX_ATTEMPTS) {
303
+ pendingOtpChallenges.delete(cleanMobile);
304
+ res.status(429).json({ error: 'Too many incorrect attempts. Please request a new OTP.' });
305
+ return;
306
+ }
307
+ if (!safeEqual(pending.otpCode, otp)) {
308
+ pending.attempts = (pending.attempts || 0) + 1;
309
+ res.status(400).json({ error: 'Invalid verification code. Please check and try again.' });
310
+ return;
311
+ }
312
+ if (new Date() > new Date(pending.expiresAt)) {
313
+ pendingOtpChallenges.delete(cleanMobile);
314
+ res.status(400).json({ error: 'Verification code has expired. Please request a new OTP.' });
315
+ return;
316
+ }
317
+
318
+ // Create new customer account in DB
319
+ const resolvedName = (body.name || body.fullName || pending.name || `Customer ${cleanMobile.slice(-4)}`).trim();
320
+ const resolvedEmail = normalizeEmail(body.email || pending.email) || `${cleanMobile}@customer.elpay.local`;
321
+
322
+ try {
323
+ customer = ctx.customerRepo.create({
324
+ name: resolvedName,
325
+ email: resolvedEmail,
326
+ mobileNumber: cleanMobile,
327
+ isPhoneVerified: true
328
+ });
329
+ } catch (err) {
330
+ res.status(400).json({ error: err.message });
331
+ return;
332
+ }
333
+
334
+ pendingOtpChallenges.delete(cleanMobile);
335
+ }
336
+
337
+ // Server-side Ban Enforcement (Section 3)
338
+ if (customer.status === 'BANNED') {
339
+ res.status(403).json({
340
+ error: 'ACCESS BLOCKED',
341
+ message: 'Your account has been temporarily blocked because suspicious payment activity was detected. Our team will review the activity before access can be restored.',
342
+ isBanned: true
343
+ });
344
+ return;
345
+ }
346
+
347
+ // Issue server-authoritative session
348
+ const ipAddress = req.socket?.remoteAddress || null;
349
+ const userAgent = req.headers['user-agent'] || null;
350
+ const session = ctx.customerSessionRepo.create({
351
+ customerId: customer.id,
352
+ ipAddress,
353
+ userAgent
354
+ });
355
+
356
+ res.setHeader('Set-Cookie', buildSessionCookie(session.token));
357
+
358
+ if (ctx.auditRepo) {
359
+ ctx.auditRepo.log({
360
+ entityType: 'CUSTOMER',
361
+ entityId: customer.id,
362
+ action: 'CUSTOMER_WHATSAPP_OTP_LOGGED_IN',
363
+ actorType: 'CUSTOMER',
364
+ actorId: customer.id,
365
+ details: { mobileNumber: customer.mobileNumber, email: customer.email }
366
+ });
367
+ }
368
+
369
+ res.status(200).json({
370
+ success: true,
371
+ token: session.token,
372
+ customerId: customer.id,
373
+ phoneNumber: customer.mobileNumber,
374
+ fullName: customer.name,
375
+ customer: {
376
+ id: customer.id,
377
+ name: customer.name,
378
+ email: customer.email,
379
+ mobileNumber: customer.mobileNumber,
380
+ status: customer.status,
381
+ lastLoginAt: customer.lastLoginAt
382
+ }
383
+ });
384
+ });
385
+
386
+ // POST /api/v1/customer/register (Gateway-owned Sign Up)
387
+ // Input: exactly name, email, mobileNumber, password
388
+ router.post('/api/v1/customer/register', async (req, res) => {
389
+ const body = req.body || {};
390
+ const { name, email, mobileNumber, password } = body;
391
+
392
+ // Backward-compatibility field fallback if legacy fields are sent
393
+ const resolvedName = (name || body.fullName || '').trim();
394
+ const resolvedMobile = normalizeMobile(mobileNumber || body.phoneNumber);
395
+ let resolvedEmail = normalizeEmail(email);
396
+ if (!resolvedEmail && resolvedMobile) {
397
+ resolvedEmail = `${resolvedMobile}@customer.elpay.local`;
398
+ }
399
+ const resolvedPassword = password;
400
+
401
+ if (!resolvedName) {
402
+ res.status(400).json({ error: 'Name is required.' });
403
+ return;
404
+ }
405
+ if (!resolvedEmail || !isValidEmail(resolvedEmail)) {
406
+ res.status(400).json({ error: 'A valid email address is required.' });
407
+ return;
408
+ }
409
+ if (!resolvedMobile || !isValidMobile(resolvedMobile)) {
410
+ res.status(400).json({ error: 'A valid 10-digit mobile number is required.' });
411
+ return;
412
+ }
413
+ if (!isValidPassword(resolvedPassword)) {
414
+ res.status(400).json({ error: 'Password must be at least 6 characters long.' });
415
+ return;
416
+ }
417
+
418
+ // Check unique email
419
+ const existingByEmail = ctx.customerRepo.findByEmail(resolvedEmail);
420
+ if (existingByEmail) {
421
+ res.status(409).json({ error: 'An account with this email address already exists.' });
422
+ return;
423
+ }
424
+
425
+ // Check unique mobile
426
+ const existingByMobile = ctx.customerRepo.findByMobile(resolvedMobile);
427
+ if (existingByMobile) {
428
+ res.status(409).json({ error: 'An account with this mobile number already exists.' });
429
+ return;
430
+ }
431
+
432
+ let customer;
433
+ try {
434
+ customer = ctx.customerRepo.create({
435
+ name: resolvedName,
436
+ email: resolvedEmail,
437
+ mobileNumber: resolvedMobile,
438
+ password: resolvedPassword
439
+ });
440
+ } catch (err) {
441
+ res.status(400).json({ error: err.message });
442
+ return;
443
+ }
444
+
445
+ // Create authenticated server-authoritative session
446
+ const ipAddress = req.socket?.remoteAddress || null;
447
+ const userAgent = req.headers['user-agent'] || null;
448
+ const session = ctx.customerSessionRepo.create({
449
+ customerId: customer.id,
450
+ ipAddress,
451
+ userAgent
452
+ });
453
+
454
+ res.setHeader('Set-Cookie', buildSessionCookie(session.token));
455
+
456
+ if (ctx.auditRepo) {
457
+ ctx.auditRepo.log({
458
+ entityType: 'CUSTOMER',
459
+ entityId: customer.id,
460
+ action: 'CUSTOMER_REGISTERED',
461
+ actorType: 'CUSTOMER',
462
+ actorId: customer.id,
463
+ details: { email: customer.email, mobileNumber: customer.mobileNumber }
464
+ });
465
+ }
466
+
467
+ res.status(201).json({
468
+ success: true,
469
+ token: session.token,
470
+ customerId: customer.id,
471
+ phoneNumber: customer.mobileNumber,
472
+ fullName: customer.name,
473
+ customer: {
474
+ id: customer.id,
475
+ name: customer.name,
476
+ email: customer.email,
477
+ mobileNumber: customer.mobileNumber,
478
+ status: customer.status,
479
+ createdAt: customer.createdAt
480
+ }
481
+ });
482
+ });
483
+
484
+ // POST /api/v1/customer/login (Gateway-owned Sign In)
485
+ // Input: identifier (email or mobile) + password
486
+ router.post('/api/v1/customer/login', async (req, res) => {
487
+ const body = req.body || {};
488
+ const identifier = (body.identifier || body.email || body.mobileNumber || body.phoneNumber || '').trim();
489
+ const password = body.password;
490
+
491
+ if (!identifier || !password) {
492
+ res.status(400).json({ error: 'Email/mobile number and password are required.' });
493
+ return;
494
+ }
495
+
496
+ const verified = ctx.customerRepo.verifyCredentials(identifier, password);
497
+ if (!verified) {
498
+ res.status(401).json({ error: 'Invalid credentials. Please check your email/mobile and password.' });
499
+ return;
500
+ }
501
+
502
+ // Server-side Ban Enforcement (Section 3)
503
+ if (verified.status === 'BANNED') {
504
+ res.status(403).json({
505
+ error: 'ACCESS BLOCKED',
506
+ message: 'Your account has been temporarily blocked because suspicious payment activity was detected. Our team will review the activity before access can be restored.',
507
+ isBanned: true
508
+ });
509
+ return;
510
+ }
511
+
512
+ // Create session
513
+ const ipAddress = req.socket?.remoteAddress || null;
514
+ const userAgent = req.headers['user-agent'] || null;
515
+ const session = ctx.customerSessionRepo.create({
516
+ customerId: verified.id,
517
+ ipAddress,
518
+ userAgent
519
+ });
520
+
521
+ res.setHeader('Set-Cookie', buildSessionCookie(session.token));
522
+
523
+ if (ctx.auditRepo) {
524
+ ctx.auditRepo.log({
525
+ entityType: 'CUSTOMER',
526
+ entityId: verified.id,
527
+ action: 'CUSTOMER_LOGGED_IN',
528
+ actorType: 'CUSTOMER',
529
+ actorId: verified.id,
530
+ details: { identifier }
531
+ });
532
+ }
533
+
534
+ res.status(200).json({
535
+ success: true,
536
+ token: session.token,
537
+ customerId: verified.id,
538
+ phoneNumber: verified.mobileNumber,
539
+ fullName: verified.name,
540
+ customer: {
541
+ id: verified.id,
542
+ name: verified.name,
543
+ email: verified.email,
544
+ mobileNumber: verified.mobileNumber,
545
+ status: verified.status,
546
+ lastLoginAt: verified.lastLoginAt
547
+ }
548
+ });
549
+ });
550
+
551
+ // GET /api/v1/customer/me (Verify active session & return profile)
552
+ router.get('/api/v1/customer/me', async (req, res) => {
553
+ const token = extractSessionToken(req);
554
+ if (!token) {
555
+ res.status(401).json({ error: 'No active session token provided.' });
556
+ return;
557
+ }
558
+
559
+ const authContext = ctx.customerSessionRepo.findByToken(token);
560
+ if (!authContext) {
561
+ res.status(401).json({ error: 'Session expired or invalid.' });
562
+ return;
563
+ }
564
+
565
+ const { customer, session } = authContext;
566
+ res.status(200).json({
567
+ authenticated: true,
568
+ customer: {
569
+ id: customer.id,
570
+ name: customer.name,
571
+ email: customer.email,
572
+ mobileNumber: customer.mobileNumber,
573
+ status: customer.status,
574
+ lastLoginAt: customer.lastLoginAt
575
+ },
576
+ session: {
577
+ id: session.id,
578
+ expiresAt: session.expiresAt
579
+ }
580
+ });
581
+ });
582
+
583
+ // POST /api/v1/customer/logout (Revoke active session token)
584
+ router.post('/api/v1/customer/logout', async (req, res) => {
585
+ const body = req.body || {};
586
+ const token = extractSessionToken(req) || body.token;
587
+
588
+ if (token) {
589
+ ctx.customerSessionRepo.revokeToken(token);
590
+ }
591
+
592
+ res.setHeader('Set-Cookie', 'elpay_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax');
593
+ res.status(200).json({ success: true, message: 'Logged out successfully.' });
594
+ });
595
+
596
+ // POST /api/v1/customer/switch-account (Validates target saved account token)
597
+ router.post('/api/v1/customer/switch-account', async (req, res) => {
598
+ const body = req.body || {};
599
+ const targetToken = body.token || body.sessionToken;
600
+
601
+ if (!targetToken) {
602
+ res.status(400).json({ error: 'Target account session token is required.' });
603
+ return;
604
+ }
605
+
606
+ const authContext = ctx.customerSessionRepo.findByToken(targetToken);
607
+ if (!authContext) {
608
+ res.status(401).json({ error: 'Target account session has expired. Please log in again.' });
609
+ return;
610
+ }
611
+
612
+ res.setHeader('Set-Cookie', buildSessionCookie(authContext.session.token));
613
+
614
+ res.status(200).json({
615
+ success: true,
616
+ token: authContext.session.token,
617
+ customer: {
618
+ id: authContext.customer.id,
619
+ name: authContext.customer.name,
620
+ email: authContext.customer.email,
621
+ mobileNumber: authContext.customer.mobileNumber
622
+ }
623
+ });
624
+ });
625
+
626
+ // Decommissioned Customer Endpoints (HTTP 404)
627
+ router.get('/api/v1/customer/payments', async (req, res) => {
628
+ res.status(404).json({ error: 'Feature not available' });
629
+ });
630
+
631
+ router.post('/api/v1/customer/tickets', async (req, res) => {
632
+ res.status(404).json({ error: 'Feature not available' });
633
+ });
634
+
635
+ router.get('/api/v1/customer/tickets/:id/messages', async (req, res) => {
636
+ res.status(404).json({ error: 'Feature not available' });
637
+ });
638
+
639
+ router.post('/api/v1/customer/tickets/:id/messages', async (req, res) => {
640
+ res.status(404).json({ error: 'Feature not available' });
641
+ });
642
+
643
+ router.get('/api/v1/customer/tickets/:id/stream', async (req, res) => {
644
+ res.status(404).json({ error: 'Feature not available' });
645
+ });
646
+ }
647
+
648
+
649
+
650
+