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