Mbonea Claude Sonnet 4.6 commited on
Commit
c33bf01
·
0 Parent(s):

Initial commit — multi-tenant WiFi hotspot monetization platform

Browse files

Node.js/Express backend with Omada SDN integration, M-Pesa payments via
Snippe, SMS notifications, captive portal, and tenant dashboard API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.env.example ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Server
2
+ PORT=3000
3
+ NODE_ENV=development
4
+
5
+ # Portal — host:port as Omada sees it (no scheme)
6
+ PORTAL_HOST=your-domain.com:3000
7
+ PORTAL_SCHEME=http
8
+
9
+ # MySQL
10
+ DB_HOST=localhost
11
+ DB_PORT=3306
12
+ DB_USER=wifiuser
13
+ DB_PASS=wifipass
14
+ DB_NAME=wifiplatform
15
+
16
+ # JWT
17
+ JWT_SECRET=change_me_to_a_long_random_string_min_32_chars
18
+ JWT_ADMIN_SECRET=change_me_admin_secret_min_32_chars
19
+
20
+ # Omada Controller
21
+ OMADA_URL=https://13.63.50.201:8043
22
+ OMADA_USER=Mbonea
23
+ OMADA_PASS=yourpassword
24
+
25
+ # Snippe Payments
26
+ SNIPPE_API_KEY=snp_live_...
27
+ SNIPPE_WEBHOOK_SECRET=whsec_...
28
+
29
+ # SMS Gateway (Android SMS Gateway)
30
+ SMS_GATEWAY_URL=https://your-sms-gateway.hf.space/api/3rdparty/v1/messages
31
+ SMS_GATEWAY_USER=your_user
32
+ SMS_GATEWAY_PASS=your_pass
33
+
34
+ # Admin access token (simple bearer token for admin endpoints)
35
+ ADMIN_TOKEN=change_me_strong_admin_token
36
+
37
+ # Platform config
38
+ PLATFORM_COMMISSION_RATE=0.05
39
+ SNIPPE_FEE_RATE=0.005
40
+ DEVICE_MONTHLY_FEE=20000
41
+ DEVICE_TRIAL_DAYS=14
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Environment variables
5
+ .env
6
+ .env.local
7
+ .env.*.local
8
+
9
+ # Logs
10
+ logs/
11
+ *.log
12
+ npm-debug.log*
13
+
14
+ # Runtime
15
+ pids/
16
+ *.pid
17
+
18
+ # OS
19
+ .DS_Store
20
+ Thumbs.db
21
+
22
+ # Editor
23
+ .vscode/
24
+ .idea/
25
+
26
+ # Build / uploads
27
+ dist/
28
+ portals/uploads/
API_DOCUMENTATION.md ADDED
@@ -0,0 +1,1018 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WiFi Platform — Backend API Documentation
2
+
3
+ > For frontend engineers building the Tenant Dashboard and Super Admin Panel.
4
+ > Base URL: `http://{SERVER_HOST}:3000`
5
+ > All API responses are JSON. All request bodies must be `Content-Type: application/json`.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ 1. [Authentication](#1-authentication)
12
+ 2. [Tenant — Devices](#2-tenant--devices)
13
+ 3. [Tenant — WiFi Plans](#3-tenant--wifi-plans)
14
+ 4. [Tenant — Dashboard](#4-tenant--dashboard)
15
+ 5. [Tenant — Sessions](#5-tenant--sessions)
16
+ 6. [Tenant — Payouts](#6-tenant--payouts)
17
+ 7. [Tenant — Alerts](#7-tenant--alerts)
18
+ 8. [Super Admin](#8-super-admin)
19
+ 9. [Captive Portal (Public)](#9-captive-portal-public)
20
+ 10. [Error Format](#10-error-format)
21
+ 11. [Enums & Constants](#11-enums--constants)
22
+ 12. [UI Building Guide](#12-ui-building-guide)
23
+
24
+ ---
25
+
26
+ ## 1. Authentication
27
+
28
+ All tenant endpoints require a `Bearer` token in the `Authorization` header:
29
+
30
+ ```
31
+ Authorization: Bearer <jwt_token>
32
+ ```
33
+
34
+ The token is returned on login and registration. It expires after **30 days**.
35
+
36
+ ---
37
+
38
+ ### POST `/api/auth/register`
39
+ Register a new tenant account.
40
+
41
+ **Body:**
42
+ ```json
43
+ {
44
+ "business_name": "Sunset Hostel",
45
+ "contact_name": "John Doe",
46
+ "email": "john@sunset.co.tz",
47
+ "phone": "0712345678",
48
+ "password": "mypassword"
49
+ }
50
+ ```
51
+
52
+ **Validation:**
53
+ - All fields required
54
+ - `password` minimum 8 characters
55
+ - `email` and `phone` must be unique
56
+
57
+ **Response `201`:**
58
+ ```json
59
+ {
60
+ "token": "eyJhbGci...",
61
+ "client": {
62
+ "id": 1,
63
+ "business_name": "Sunset Hostel",
64
+ "contact_name": "John Doe",
65
+ "email": "john@sunset.co.tz",
66
+ "phone": "0712345678",
67
+ "balance": 0,
68
+ "total_earned": 0,
69
+ "total_withdrawn": 0,
70
+ "is_active": 1,
71
+ "created_at": "2026-04-15T10:00:00.000Z"
72
+ }
73
+ }
74
+ ```
75
+
76
+ > A welcome SMS is sent to `phone` on success.
77
+
78
+ ---
79
+
80
+ ### POST `/api/auth/login`
81
+ Login with email and password.
82
+
83
+ **Body:**
84
+ ```json
85
+ {
86
+ "email": "john@sunset.co.tz",
87
+ "password": "mypassword"
88
+ }
89
+ ```
90
+
91
+ **Response `200`:**
92
+ ```json
93
+ {
94
+ "token": "eyJhbGci...",
95
+ "client": { ...same as register... }
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ### POST `/api/auth/forgot-password`
102
+ Request a password reset OTP via SMS.
103
+
104
+ **Body:**
105
+ ```json
106
+ { "phone": "0712345678" }
107
+ ```
108
+
109
+ **Response `200`:**
110
+ ```json
111
+ { "message": "If that number is registered, an OTP has been sent." }
112
+ ```
113
+
114
+ > Always returns 200 regardless of whether the phone exists — prevents enumeration.
115
+ > OTP is a 6-digit code, valid for **10 minutes**, single-use.
116
+ > Rate limited to 3 requests per 15 minutes per IP.
117
+
118
+ ---
119
+
120
+ ### POST `/api/auth/reset-password`
121
+ Set a new password using the OTP received via SMS.
122
+
123
+ **Body:**
124
+ ```json
125
+ {
126
+ "phone": "0712345678",
127
+ "otp": "482910",
128
+ "new_password": "newsecurepassword"
129
+ }
130
+ ```
131
+
132
+ **Validation:**
133
+ - `new_password` minimum 8 characters
134
+ - OTP must be unused and not expired
135
+
136
+ **Response `200`:**
137
+ ```json
138
+ { "message": "Password reset successfully. Please log in." }
139
+ ```
140
+
141
+ **Error `400`:** `"Invalid or expired OTP"`
142
+
143
+ ---
144
+
145
+ ### GET `/api/auth/me`
146
+ Get the authenticated tenant's profile.
147
+
148
+ **Auth:** Required
149
+
150
+ **Response `200`:**
151
+ ```json
152
+ {
153
+ "id": 1,
154
+ "business_name": "Sunset Hostel",
155
+ "contact_name": "John Doe",
156
+ "email": "john@sunset.co.tz",
157
+ "phone": "0712345678",
158
+ "balance": 45000.00,
159
+ "total_earned": 320000.00,
160
+ "total_withdrawn": 275000.00,
161
+ "created_at": "2026-04-15T10:00:00.000Z"
162
+ }
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 2. Tenant — Devices
168
+
169
+ A **device** represents one WiFi access point (AP) registered to a tenant. Each device maps to one Omada site and has its own captive portal, WiFi plans, billing, and guest sessions.
170
+
171
+ ---
172
+
173
+ ### GET `/api/devices`
174
+ List all devices for the authenticated tenant.
175
+
176
+ **Auth:** Required
177
+
178
+ **Response `200`:**
179
+ ```json
180
+ [
181
+ {
182
+ "id": 3,
183
+ "name": "Main Building",
184
+ "location": "Floor 1",
185
+ "mac": "24-2F-D0-DF-19-9C",
186
+ "model": "EAP110-Outdoor",
187
+ "ip": "192.168.1.137",
188
+ "status": "online",
189
+ "billing_status": "active",
190
+ "billing_expires_at": "2026-05-15T00:00:00.000Z",
191
+ "monthly_fee": 20000.00,
192
+ "cpu_util": 12,
193
+ "mem_util": 63,
194
+ "guest_count": 4,
195
+ "client_count": 5,
196
+ "last_seen_at": "2026-04-15T11:30:00.000Z",
197
+ "registered_at": "2026-03-01T08:00:00.000Z",
198
+ "omada_site_id": "69dcc0a73044367bfbc43692"
199
+ }
200
+ ]
201
+ ```
202
+
203
+ **`status` values:** `pending` · `adopting` · `online` · `offline` · `removed`
204
+ **`billing_status` values:** `trial` · `active` · `suspended` · `cancelled`
205
+
206
+ ---
207
+
208
+ ### POST `/api/devices`
209
+ Register a new WiFi access point.
210
+
211
+ **Auth:** Required
212
+
213
+ **Body:**
214
+ ```json
215
+ {
216
+ "name": "Main Building",
217
+ "mac": "24:2F:D0:DF:19:9C",
218
+ "location": "Floor 1",
219
+ "device_username": "admin",
220
+ "device_password": "MyCustomPass@123"
221
+ }
222
+ ```
223
+
224
+ **Notes:**
225
+ - `name` and `mac` are required. All other fields are optional.
226
+ - MAC is auto-normalized to `AA-BB-CC-DD-EE-FF` format.
227
+ - `device_username` / `device_password` are the credentials the AP was configured with. If not supplied, Omada's auto-generated site credentials are used. Supply these if the AP already has custom credentials set before registration.
228
+ - An Omada site and captive portal are created automatically.
229
+ - Device starts in `trial` billing status (default 14-day trial).
230
+
231
+ **Response `201`:**
232
+ ```json
233
+ {
234
+ "device": { ...full device object... },
235
+ "message": "Device registered. Plug in your AP and wait for it to come online."
236
+ }
237
+ ```
238
+
239
+ ---
240
+
241
+ ### GET `/api/devices/:id`
242
+ Get a single device with today's stats and active guest sessions.
243
+
244
+ **Auth:** Required
245
+
246
+ **Response `200`:**
247
+ ```json
248
+ {
249
+ "id": 3,
250
+ "name": "Main Building",
251
+ "status": "online",
252
+ "billing_status": "active",
253
+ "sales_today": 12,
254
+ "earned_today": 11400.00,
255
+ "active_sessions": [
256
+ {
257
+ "client_mac": "2A-61-D9-9B-90-7C",
258
+ "started_at": "2026-04-15T09:00:00.000Z",
259
+ "bytes_down": 4321769,
260
+ "bytes_up": 6990620,
261
+ "expires_at": "2026-04-15T11:00:00.000Z",
262
+ "code": "ABCD-1234",
263
+ "plan_name": "2 Hour Plan"
264
+ }
265
+ ]
266
+ }
267
+ ```
268
+
269
+ ---
270
+
271
+ ### DELETE `/api/devices/:id`
272
+ Pause a device (suspends billing, kicks all connected guests, revokes active tokens).
273
+
274
+ **Auth:** Required
275
+
276
+ > This is reversible — use `POST /api/devices/:id/renew` to reactivate.
277
+ > The captive portal will show a "Renew Subscription" page to guests.
278
+ > Tenant receives an SMS with the renewal amount.
279
+
280
+ **Response `200`:**
281
+ ```json
282
+ {
283
+ "message": "Device paused. Renew your subscription to reactivate."
284
+ }
285
+ ```
286
+
287
+ ---
288
+
289
+ ### POST `/api/devices/:id/renew`
290
+ Initiate a monthly subscription payment for a device via M-Pesa.
291
+
292
+ **Auth:** Required
293
+
294
+ **Body:** *(empty)*
295
+
296
+ **Response `200`:**
297
+ ```json
298
+ {
299
+ "reference": "abc123def456ghi789",
300
+ "amount": 20000,
301
+ "message": "Check your phone for M-Pesa prompt"
302
+ }
303
+ ```
304
+
305
+ > Payment is processed via M-Pesa. On success the webhook automatically extends `billing_expires_at` by 30 days and sets `billing_status = 'active'`.
306
+
307
+ ---
308
+
309
+ ## 3. Tenant — WiFi Plans
310
+
311
+ Plans define what guests can purchase on the captive portal. Each plan belongs to a specific device.
312
+
313
+ ---
314
+
315
+ ### GET `/api/plans?deviceId=:id`
316
+ List all plans (active and inactive) for a device.
317
+
318
+ **Auth:** Required
319
+ **Query:** `deviceId` (required)
320
+
321
+ **Response `200`:**
322
+ ```json
323
+ [
324
+ {
325
+ "id": 7,
326
+ "client_id": 1,
327
+ "device_id": 3,
328
+ "name": "1 Hour Browsing",
329
+ "duration_seconds": 3600,
330
+ "price": 500.00,
331
+ "down_limit": 5,
332
+ "down_unit": 2,
333
+ "up_limit": 2,
334
+ "up_unit": 2,
335
+ "display_order": 0,
336
+ "is_active": 1,
337
+ "created_at": "2026-03-01T08:00:00.000Z"
338
+ }
339
+ ]
340
+ ```
341
+
342
+ **Speed units:** `1` = Kbps · `2` = Mbps
343
+
344
+ ---
345
+
346
+ ### POST `/api/plans`
347
+ Create a new WiFi plan.
348
+
349
+ **Auth:** Required
350
+
351
+ **Body:**
352
+ ```json
353
+ {
354
+ "device_id": 3,
355
+ "name": "1 Hour Browsing",
356
+ "duration_seconds": 3600,
357
+ "price": 500,
358
+ "down_limit": 5,
359
+ "down_unit": 2,
360
+ "up_limit": 2,
361
+ "up_unit": 2,
362
+ "display_order": 0
363
+ }
364
+ ```
365
+
366
+ **Required:** `device_id`, `name`, `duration_seconds`, `price`
367
+ **Defaults:** `down_limit: 2`, `down_unit: 2`, `up_limit: 2`, `up_unit: 2`, `display_order: 0`
368
+
369
+ **Response `201`:** Full plan object.
370
+
371
+ ---
372
+
373
+ ### PUT `/api/plans/:id`
374
+ Update a plan. All fields are optional (partial update supported).
375
+
376
+ **Auth:** Required
377
+
378
+ **Body (all optional):**
379
+ ```json
380
+ {
381
+ "name": "1 Hour Plan",
382
+ "duration_seconds": 3600,
383
+ "price": 600,
384
+ "down_limit": 10,
385
+ "down_unit": 2,
386
+ "up_limit": 5,
387
+ "up_unit": 2,
388
+ "display_order": 1,
389
+ "is_active": 1
390
+ }
391
+ ```
392
+
393
+ **Response `200`:** Updated plan object.
394
+
395
+ > Set `is_active: 0` to hide a plan from the portal without deleting it.
396
+
397
+ ---
398
+
399
+ ### DELETE `/api/plans/:id`
400
+ Deactivate a plan (soft delete — it stops appearing on the portal).
401
+
402
+ **Auth:** Required
403
+
404
+ **Response `200`:**
405
+ ```json
406
+ { "message": "Plan deactivated" }
407
+ ```
408
+
409
+ ---
410
+
411
+ ## 4. Tenant — Dashboard
412
+
413
+ ---
414
+
415
+ ### GET `/api/dashboard/summary`
416
+ Main dashboard overview for the authenticated tenant.
417
+
418
+ **Auth:** Required
419
+
420
+ **Response `200`:**
421
+ ```json
422
+ {
423
+ "balance": 45000.00,
424
+ "total_earned": 320000.00,
425
+ "total_withdrawn": 275000.00,
426
+ "alert_count": 2,
427
+ "devices": [
428
+ {
429
+ "id": 3,
430
+ "name": "Main Building",
431
+ "location": "Floor 1",
432
+ "status": "online",
433
+ "billing_status": "active",
434
+ "billing_expires_at": "2026-05-15T00:00:00.000Z",
435
+ "model": "EAP110-Outdoor",
436
+ "cpu_util": 12,
437
+ "mem_util": 63,
438
+ "guest_count": 4,
439
+ "last_seen_at": "2026-04-15T11:30:00.000Z",
440
+ "sales_today": 12,
441
+ "earned_today": 11400.00,
442
+ "guests_online": 4
443
+ }
444
+ ]
445
+ }
446
+ ```
447
+
448
+ ---
449
+
450
+ ### GET `/api/dashboard/revenue/weekly`
451
+ Last 7 days of revenue broken down by day.
452
+
453
+ **Auth:** Required
454
+
455
+ **Response `200`:**
456
+ ```json
457
+ [
458
+ {
459
+ "day": "2026-04-15",
460
+ "sales": 12,
461
+ "gross": 12000.00,
462
+ "net": 11340.00
463
+ }
464
+ ]
465
+ ```
466
+
467
+ > `net` = `gross` minus Snippe fee (0.5%) minus platform commission (varies by tenant, default 5%).
468
+ > Days with no sales are omitted — fill gaps in the frontend.
469
+
470
+ ---
471
+
472
+ ## 5. Tenant — Sessions
473
+
474
+ ---
475
+
476
+ ### GET `/api/sessions/active`
477
+ All currently active guest sessions across all devices.
478
+
479
+ **Auth:** Required
480
+
481
+ **Response `200`:**
482
+ ```json
483
+ [
484
+ {
485
+ "id": 201,
486
+ "client_mac": "2A-61-D9-9B-90-7C",
487
+ "client_ip": "192.168.1.156",
488
+ "ssid": "Sunset Hostel WiFi",
489
+ "started_at": "2026-04-15T09:00:00.000Z",
490
+ "last_seen_at": "2026-04-15T11:25:00.000Z",
491
+ "bytes_down": 4321769,
492
+ "bytes_up": 6990620,
493
+ "duration_seconds": 8700,
494
+ "rssi": -42,
495
+ "signal_level": 80,
496
+ "device_name": "Main Building",
497
+ "token_code": "ABCD-1234",
498
+ "expires_at": "2026-04-15T11:00:00.000Z",
499
+ "plan_name": "2 Hour Plan"
500
+ }
501
+ ]
502
+ ```
503
+
504
+ ---
505
+
506
+ ### GET `/api/sessions`
507
+ Session history (active and ended).
508
+
509
+ **Auth:** Required
510
+ **Query params:**
511
+ - `deviceId` *(optional)* — filter by device
512
+ - `limit` *(optional, default 50)* — max records to return
513
+
514
+ **Response `200`:** Array of session objects including `ended_at` and `is_active`.
515
+
516
+ ---
517
+
518
+ ## 6. Tenant — Payouts
519
+
520
+ Tenants withdraw their earned balance via M-Pesa.
521
+
522
+ ---
523
+
524
+ ### GET `/api/payouts`
525
+ Payout history for the authenticated tenant (last 50).
526
+
527
+ **Auth:** Required
528
+
529
+ **Response `200`:**
530
+ ```json
531
+ [
532
+ {
533
+ "id": 5,
534
+ "amount": 50000.00,
535
+ "payout_fee": 1500.00,
536
+ "net_amount": 48500.00,
537
+ "destination_phone": "0712345678",
538
+ "destination_provider": "mpesa",
539
+ "status": "completed",
540
+ "failure_reason": null,
541
+ "created_at": "2026-04-10T14:00:00.000Z",
542
+ "completed_at": "2026-04-10T14:02:00.000Z"
543
+ }
544
+ ]
545
+ ```
546
+
547
+ **`status` values:** `pending` · `processing` · `completed` · `failed`
548
+
549
+ ---
550
+
551
+ ### POST `/api/payouts`
552
+ Request a withdrawal to M-Pesa.
553
+
554
+ **Auth:** Required
555
+
556
+ **Body:**
557
+ ```json
558
+ {
559
+ "amount": 50000,
560
+ "phone": "0712345678",
561
+ "provider": "mpesa"
562
+ }
563
+ ```
564
+
565
+ **Validation:**
566
+ - `amount` minimum: 5,000 TZS
567
+ - `amount` must not exceed `balance`
568
+ - `provider` options: `mpesa` · `airtel` · `tigo` · `halopesa` (default: `mpesa`)
569
+
570
+ **Response `201`:**
571
+ ```json
572
+ {
573
+ "id": 6,
574
+ "amount": 50000,
575
+ "payout_fee": 1500,
576
+ "net_amount": 48500,
577
+ "status": "processing",
578
+ "message": "Payout initiated. Funds will arrive shortly."
579
+ }
580
+ ```
581
+
582
+ > Balance is deducted immediately on request. If the payout fails, balance is automatically restored. Tenant receives an SMS on completion or failure.
583
+
584
+ ---
585
+
586
+ ## 7. Tenant — Alerts
587
+
588
+ Alerts are AP events synced from the Omada controller (device offline, client blocked, etc.).
589
+
590
+ ---
591
+
592
+ ### GET `/api/alerts/unresolved`
593
+ All unresolved alerts for the tenant (last 50, newest first).
594
+
595
+ **Auth:** Required
596
+
597
+ **Response `200`:**
598
+ ```json
599
+ [
600
+ {
601
+ "id": 12,
602
+ "alert_key": "DEV_DISCONN",
603
+ "level": "error",
604
+ "content": "[ap:24-2F-D0-DF-19-9C] was disconnected.",
605
+ "device_mac": "24-2F-D0-DF-19-9C",
606
+ "alert_time": "2026-04-15T08:00:00.000Z",
607
+ "fetched_at": "2026-04-15T08:15:00.000Z",
608
+ "device_name": "Main Building"
609
+ }
610
+ ]
611
+ ```
612
+
613
+ **`level` values:** `info` · `warning` · `error` · `critical`
614
+ **Common `alert_key` values:** `DEV_DISCONN` · `DEV_CONN` · `CLIENT_BLOCK`
615
+
616
+ ---
617
+
618
+ ### PATCH `/api/alerts/:id/resolve`
619
+ Mark an alert as resolved.
620
+
621
+ **Auth:** Required
622
+
623
+ **Response `200`:**
624
+ ```json
625
+ { "message": "Alert resolved" }
626
+ ```
627
+
628
+ ---
629
+
630
+ ## 8. Super Admin
631
+
632
+ All admin endpoints require a static admin token passed as a Bearer token.
633
+ This is a **separate secret** from tenant JWTs — set via `ADMIN_TOKEN` environment variable.
634
+
635
+ ```
636
+ Authorization: Bearer <ADMIN_TOKEN>
637
+ ```
638
+
639
+ ---
640
+
641
+ ### GET `/api/admin/overview`
642
+ Platform-wide stats for the admin dashboard.
643
+
644
+ **Response `200`:**
645
+ ```json
646
+ {
647
+ "tenants": 24,
648
+ "devices_online": 18,
649
+ "devices_offline": 3,
650
+ "devices_suspended": 2,
651
+ "guests_online": 47,
652
+ "sales_today": 312,
653
+ "gross_today": 312000.00,
654
+ "commission_today": 15600.00,
655
+ "snippe_fees_today": 1560.00,
656
+ "pending_payouts": 5,
657
+ "pending_payout_total": 230000.00
658
+ }
659
+ ```
660
+
661
+ ---
662
+
663
+ ### GET `/api/admin/tenants`
664
+ List all tenants with device counts.
665
+
666
+ **Response `200`:**
667
+ ```json
668
+ [
669
+ {
670
+ "id": 1,
671
+ "business_name": "Sunset Hostel",
672
+ "contact_name": "John Doe",
673
+ "email": "john@sunset.co.tz",
674
+ "phone": "0712345678",
675
+ "balance": 45000.00,
676
+ "total_earned": 320000.00,
677
+ "total_withdrawn": 275000.00,
678
+ "is_active": 1,
679
+ "created_at": "2026-03-01T08:00:00.000Z",
680
+ "device_count": 2,
681
+ "devices_online": 1
682
+ }
683
+ ]
684
+ ```
685
+
686
+ ---
687
+
688
+ ### GET `/api/admin/devices`
689
+ List all devices across all tenants.
690
+
691
+ **Response `200`:**
692
+ ```json
693
+ [
694
+ {
695
+ "id": 3,
696
+ "name": "Main Building",
697
+ "location": "Floor 1",
698
+ "mac": "24-2F-D0-DF-19-9C",
699
+ "model": "EAP110-Outdoor",
700
+ "status": "online",
701
+ "billing_status": "active",
702
+ "billing_expires_at": "2026-05-15T00:00:00.000Z",
703
+ "monthly_fee": 20000.00,
704
+ "cpu_util": 12,
705
+ "mem_util": 63,
706
+ "guest_count": 4,
707
+ "last_seen_at": "2026-04-15T11:30:00.000Z",
708
+ "business_name": "Sunset Hostel",
709
+ "client_phone": "0712345678"
710
+ }
711
+ ]
712
+ ```
713
+
714
+ ---
715
+
716
+ ### GET `/api/admin/payouts/pending`
717
+ All payouts in `pending` or `processing` state across all tenants.
718
+
719
+ **Response `200`:**
720
+ ```json
721
+ [
722
+ {
723
+ "id": 6,
724
+ "amount": 50000.00,
725
+ "payout_fee": 1500.00,
726
+ "net_amount": 48500.00,
727
+ "destination_phone": "0712345678",
728
+ "destination_provider": "mpesa",
729
+ "status": "processing",
730
+ "created_at": "2026-04-15T10:00:00.000Z",
731
+ "business_name": "Sunset Hostel",
732
+ "contact_name": "John Doe"
733
+ }
734
+ ]
735
+ ```
736
+
737
+ ---
738
+
739
+ ### GET `/api/admin/revenue/daily?days=30`
740
+ Daily revenue breakdown across the entire platform.
741
+
742
+ **Query:** `days` *(optional, default 30)*
743
+
744
+ **Response `200`:**
745
+ ```json
746
+ [
747
+ {
748
+ "day": "2026-04-15",
749
+ "sales": 312,
750
+ "gross": 312000.00,
751
+ "commission": 15600.00,
752
+ "tenant_credit": 294840.00
753
+ }
754
+ ]
755
+ ```
756
+
757
+ ---
758
+
759
+ ### POST `/api/admin/devices/:id/reboot`
760
+ Send a reboot command to a specific AP via Omada.
761
+
762
+ **Response `200`:**
763
+ ```json
764
+ { "message": "Reboot command sent" }
765
+ ```
766
+
767
+ ---
768
+
769
+ ## 9. Captive Portal (Public)
770
+
771
+ These endpoints are hit by the AP firmware redirect and the guest's browser. No authentication required.
772
+
773
+ ---
774
+
775
+ ### GET `/portal/:siteId?clientMac=&apMac=&ssidName=`
776
+ Render the captive portal page for a guest.
777
+
778
+ **Query params (injected by Omada redirect):**
779
+ - `clientMac` — guest's MAC address
780
+ - `apMac` — AP MAC address
781
+ - `ssidName` — SSID name
782
+
783
+ **Behavior:**
784
+ - If device is suspended/cancelled → renders **"Subscription Required"** page
785
+ - If guest MAC has a valid active token → **auto-reconnects** silently and renders **"Welcome Back"** page
786
+ - Otherwise → renders the **portal** with available plans
787
+
788
+ > This is a server-rendered EJS page, not a JSON API.
789
+
790
+ ---
791
+
792
+ ### POST `/portal/:siteId/purchase`
793
+ Guest initiates a WiFi plan purchase.
794
+
795
+ **Body:**
796
+ ```json
797
+ {
798
+ "planId": 7,
799
+ "phone": "0712345678"
800
+ }
801
+ ```
802
+
803
+ **Response `200`:**
804
+ ```json
805
+ {
806
+ "reference": "abc123def456ghi789jkl",
807
+ "status": "pending"
808
+ }
809
+ ```
810
+
811
+ > An M-Pesa payment prompt is sent to `phone`. Poll `/check/:reference` to track completion.
812
+
813
+ ---
814
+
815
+ ### GET `/portal/:siteId/check/:reference`
816
+ Poll payment status after purchase.
817
+
818
+ **Response — pending:**
819
+ ```json
820
+ { "status": "pending" }
821
+ ```
822
+
823
+ **Response — paid:**
824
+ ```json
825
+ {
826
+ "status": "paid",
827
+ "code": "ABCD-1234",
828
+ "plan_name": "1 Hour Browsing"
829
+ }
830
+ ```
831
+
832
+ **Response — failed:**
833
+ ```json
834
+ { "status": "failed" }
835
+ ```
836
+
837
+ ---
838
+
839
+ ### POST `/portal/:siteId/auth`
840
+ Authorize a guest on the WiFi using their access code.
841
+
842
+ **Body:**
843
+ ```json
844
+ {
845
+ "code": "ABCD-1234",
846
+ "clientMac": "2a:61:d9:9b:90:7c"
847
+ }
848
+ ```
849
+
850
+ **Response `200`:**
851
+ ```json
852
+ {
853
+ "success": true,
854
+ "code": "ABCD-1234",
855
+ "expiresIn": 3600,
856
+ "expiresAt": "2026-04-15T13:00:00.000Z",
857
+ "speedDown": "5Mbps",
858
+ "speedUp": "2Mbps"
859
+ }
860
+ ```
861
+
862
+ **Error responses:**
863
+ - `404` — code not found
864
+ - `403` — code locked to a different device MAC
865
+ - `410` — code expired
866
+ - `503` — AP offline, authorization failed
867
+
868
+ > The code is locked to the guest's MAC on first use. The same guest can reconnect later using the same code — they'll be re-authorized automatically without re-entering it.
869
+
870
+ ---
871
+
872
+ ## 10. Error Format
873
+
874
+ All errors follow this shape:
875
+
876
+ ```json
877
+ { "error": "Human-readable message" }
878
+ ```
879
+
880
+ | Status | Meaning |
881
+ |--------|---------|
882
+ | `400` | Bad request — missing or invalid fields |
883
+ | `401` | Not authenticated or invalid token |
884
+ | `403` | Forbidden — resource belongs to another user |
885
+ | `404` | Resource not found |
886
+ | `409` | Conflict — duplicate email, phone, or MAC |
887
+ | `410` | Gone — token expired or revoked |
888
+ | `500` | Server error |
889
+ | `502` | Upstream error (Omada or Snippe unreachable) |
890
+ | `503` | Service unavailable (AP offline) |
891
+
892
+ ---
893
+
894
+ ## 11. Enums & Constants
895
+
896
+ ### Device `status`
897
+ | Value | Meaning |
898
+ |-------|---------|
899
+ | `pending` | Registered, AP not yet visible to Omada |
900
+ | `adopting` | Omada adoption in progress |
901
+ | `online` | AP connected and serving guests |
902
+ | `offline` | AP disconnected |
903
+ | `removed` | Decommissioned |
904
+
905
+ ### Device `billing_status`
906
+ | Value | Meaning |
907
+ |-------|---------|
908
+ | `trial` | Free trial period (default 14 days) |
909
+ | `active` | Subscription paid and active |
910
+ | `suspended` | Billing expired or manually paused — portal blocked |
911
+ | `cancelled` | Permanently decommissioned |
912
+
913
+ ### Access token `status`
914
+ | Value | Meaning |
915
+ |-------|---------|
916
+ | `unused` | Paid for, not yet activated |
917
+ | `active` | In use — locked to a MAC |
918
+ | `expired` | Time ran out |
919
+ | `revoked` | Cancelled (device suspended/paused) |
920
+
921
+ ### Speed `unit`
922
+ | Value | Meaning |
923
+ |-------|---------|
924
+ | `1` | Kbps |
925
+ | `2` | Mbps |
926
+
927
+ ### Alert `level`
928
+ | Value | Meaning |
929
+ |-------|---------|
930
+ | `info` | Informational |
931
+ | `warning` | Non-critical issue |
932
+ | `error` | AP disconnected or serious event |
933
+ | `critical` | Requires immediate action |
934
+
935
+ ### Payout / Payment `status`
936
+ | Value | Meaning |
937
+ |-------|---------|
938
+ | `pending` | Created, waiting for M-Pesa |
939
+ | `processing` | M-Pesa prompt sent |
940
+ | `completed` | Funds transferred |
941
+ | `failed` | Payment/payout failed |
942
+
943
+ ---
944
+
945
+ ## 12. UI Building Guide
946
+
947
+ ### Tenant Dashboard — Recommended Pages
948
+
949
+ | Page | Key endpoints |
950
+ |------|--------------|
951
+ | Login / Register | `POST /api/auth/login`, `POST /api/auth/register` |
952
+ | Dashboard Home | `GET /api/dashboard/summary`, `GET /api/dashboard/revenue/weekly` |
953
+ | Devices List | `GET /api/devices` |
954
+ | Device Detail | `GET /api/devices/:id`, `GET /api/plans?deviceId=`, `GET /api/sessions/active` |
955
+ | Add Device | `POST /api/devices` |
956
+ | Manage Plans | `POST /api/plans`, `PUT /api/plans/:id`, `DELETE /api/plans/:id` |
957
+ | Session History | `GET /api/sessions` |
958
+ | Payouts | `GET /api/payouts`, `POST /api/payouts` |
959
+ | Alerts | `GET /api/alerts/unresolved`, `PATCH /api/alerts/:id/resolve` |
960
+ | Profile | `GET /api/auth/me` |
961
+
962
+ ### Super Admin Panel — Recommended Pages
963
+
964
+ | Page | Key endpoints |
965
+ |------|--------------|
966
+ | Admin Overview | `GET /api/admin/overview` |
967
+ | Tenants List | `GET /api/admin/tenants` |
968
+ | Devices List | `GET /api/admin/devices` |
969
+ | Pending Payouts | `GET /api/admin/payouts/pending` |
970
+ | Revenue Chart | `GET /api/admin/revenue/daily?days=30` |
971
+ | Device Actions | `POST /api/admin/devices/:id/reboot` |
972
+
973
+ ### Key UX Notes for Frontend
974
+
975
+ **Device status indicators:**
976
+ - Show `online` as green, `offline` as red, `adopting` as yellow spinner, `pending` as grey
977
+ - `billing_status = 'suspended'` should show a prominent "Renew" CTA regardless of `status`
978
+ - Show `billing_expires_at` with a warning if within 3 days
979
+
980
+ **Balance display:**
981
+ - `balance` = available to withdraw right now
982
+ - `total_earned - total_withdrawn` should equal `balance` (use for sanity check display)
983
+ - Show earnings chart from `revenue/weekly` on dashboard home
984
+
985
+ **Payout flow:**
986
+ 1. Show current `balance` from `GET /api/auth/me`
987
+ 2. `POST /api/payouts` — deducts balance immediately on submit
988
+ 3. Poll or refresh `GET /api/payouts` to show updated status
989
+ 4. Show `net_amount` (after fee) prominently, not `amount`
990
+
991
+ **Device renewal flow:**
992
+ 1. `POST /api/devices/:id/renew`
993
+ 2. Inform user to check their phone for M-Pesa prompt
994
+ 3. Poll `GET /api/devices/:id` until `billing_status = 'active'`
995
+
996
+ **Alert badge:**
997
+ - Use `alert_count` from `GET /api/dashboard/summary` for the sidebar badge
998
+ - Refresh count after `PATCH /api/alerts/:id/resolve`
999
+
1000
+ **Data formatting:**
1001
+ - All amounts are TZS (Tanzanian Shilling) — display with `.toLocaleString()`
1002
+ - `bytes_down` / `bytes_up` are raw bytes — convert to MB/GB for display
1003
+ - `duration_seconds` — convert to human readable (e.g. `3600` → `1h`, `86400` → `1 day`)
1004
+ - All timestamps are UTC ISO strings — convert to local time for display
1005
+
1006
+ **Icons and visuals:**
1007
+ - **Do not use emojis** anywhere in the UI. Use SVG icons exclusively (e.g. Lucide, Heroicons, Feather, or inline SVG).
1008
+ - Emojis render inconsistently across operating systems, fonts, and browsers — SVGs are predictable and styleable.
1009
+ - Icon stroke color should use the active theme's primary color or semantic colors (green for success, amber for warning, red for error).
1010
+ - Recommended semantic icon mappings:
1011
+ - WiFi / connected → `wifi` icon, stroke = primary color
1012
+ - Success / authorized → `check-circle` icon, stroke = `#22c55e`
1013
+ - Suspended / locked → `lock` icon, stroke = `#f59e0b`
1014
+ - Error / failed → `x-circle` icon, stroke = `#ef4444`
1015
+ - Offline / disconnected → `wifi-off` icon, stroke = `#9ca3af`
1016
+ - Device / AP → `radio` or `router` icon
1017
+ - Payout → `send` or `credit-card` icon
1018
+ - Alert → `alert-triangle` icon, stroke = `#f59e0b`
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+ WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci --omit=dev
5
+ COPY . .
6
+ EXPOSE 3000
7
+ CMD ["node", "src/server.js"]
docker-compose.yml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ mysql:
5
+ image: mysql:8.0
6
+ container_name: wifi_mysql
7
+ restart: unless-stopped
8
+ environment:
9
+ MYSQL_ROOT_PASSWORD: rootpass
10
+ MYSQL_DATABASE: wifiplatform
11
+ MYSQL_USER: wifiuser
12
+ MYSQL_PASSWORD: wifipass
13
+ ports:
14
+ - "3306:3306"
15
+ volumes:
16
+ - mysql_data:/var/lib/mysql
17
+ - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql
18
+ command: >
19
+ --character-set-server=utf8mb4
20
+ --collation-server=utf8mb4_unicode_ci
21
+ --default-authentication-plugin=mysql_native_password
22
+ healthcheck:
23
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "wifiuser", "-pwifipass"]
24
+ interval: 10s
25
+ timeout: 5s
26
+ retries: 5
27
+
28
+ app:
29
+ build: .
30
+ container_name: wifi_app
31
+ restart: unless-stopped
32
+ depends_on:
33
+ mysql:
34
+ condition: service_healthy
35
+ env_file: .env
36
+ environment:
37
+ DB_HOST: mysql
38
+ ports:
39
+ - "3000:3000"
40
+ volumes:
41
+ - ./public:/app/public
42
+
43
+ volumes:
44
+ mysql_data:
package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "wifi-platform",
3
+ "version": "1.0.0",
4
+ "description": "Multi-tenant WiFi hotspot platform",
5
+ "main": "src/server.js",
6
+ "scripts": {
7
+ "start": "node src/server.js",
8
+ "dev": "nodemon src/server.js"
9
+ },
10
+ "dependencies": {
11
+ "axios": "^1.6.8",
12
+ "bcryptjs": "^2.4.3",
13
+ "cors": "^2.8.5",
14
+ "dotenv": "^16.4.5",
15
+ "ejs": "^3.1.10",
16
+ "express": "^4.18.3",
17
+ "express-rate-limit": "^7.3.1",
18
+ "jsonwebtoken": "^9.0.2",
19
+ "mysql2": "^3.9.2",
20
+ "node-cron": "^3.0.3",
21
+ "uuid": "^9.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "nodemon": "^3.1.0"
25
+ }
26
+ }
public/engine.js ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WiFi Portal Engine
3
+ * Handles the guest payment & authorization flow.
4
+ * Requires window.PORTAL to be set before this script loads.
5
+ */
6
+ (function () {
7
+ 'use strict';
8
+
9
+ const P = window.PORTAL;
10
+ if (!P) return;
11
+
12
+ // ── DOM refs ──────────────────────────────────────────────────────────────
13
+ const $ = id => document.getElementById(id);
14
+
15
+ const screens = {
16
+ select: $('screen-select'),
17
+ waiting: $('screen-waiting'),
18
+ code: $('screen-code'),
19
+ success: $('screen-success'),
20
+ };
21
+
22
+ function showScreen(name) {
23
+ Object.values(screens).forEach(s => s && s.classList.remove('active'));
24
+ if (screens[name]) screens[name].classList.add('active');
25
+ }
26
+
27
+ function showError(elId, msg) {
28
+ const el = $(elId);
29
+ if (!el) return;
30
+ el.textContent = msg;
31
+ el.classList.add('show');
32
+ }
33
+
34
+ function clearError(elId) {
35
+ const el = $(elId);
36
+ if (el) { el.textContent = ''; el.classList.remove('show'); }
37
+ }
38
+
39
+ // ── State ─────────────────────────────────────────────────────────────────
40
+ let selectedPlanId = null;
41
+ let selectedPlanPrice = null;
42
+ let pollTimer = null;
43
+ let currentReference = null;
44
+
45
+ // ── Plan selection ────────────────────────────────────────────────────────
46
+ const planBtns = document.querySelectorAll('.plan-btn');
47
+ const payBtn = $('pay-btn');
48
+ const phoneInput = $('phone-input');
49
+
50
+ planBtns.forEach(btn => {
51
+ btn.addEventListener('click', () => {
52
+ planBtns.forEach(b => b.classList.remove('selected'));
53
+ btn.classList.add('selected');
54
+ selectedPlanId = btn.dataset.planId;
55
+ selectedPlanPrice = btn.dataset.planPrice;
56
+ if (payBtn) {
57
+ payBtn.textContent = `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
58
+ payBtn.disabled = false;
59
+ }
60
+ });
61
+ });
62
+
63
+ // ── Pay button ────────────────────────────────────────────────────────────
64
+ if (payBtn) {
65
+ payBtn.addEventListener('click', async () => {
66
+ clearError('select-error');
67
+ const phone = phoneInput?.value?.trim();
68
+ if (!selectedPlanId) return showError('select-error', 'Please select a plan.');
69
+ if (!phone || phone.replace(/\D/g, '').length < 9) {
70
+ return showError('select-error', 'Enter a valid phone number.');
71
+ }
72
+
73
+ payBtn.disabled = true;
74
+ showScreen('waiting');
75
+
76
+ try {
77
+ const res = await fetch(`/portal/${P.siteId}/purchase`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ planId: selectedPlanId, phone }),
81
+ });
82
+ const data = await res.json();
83
+
84
+ if (!res.ok || !data.reference) {
85
+ showScreen('select');
86
+ payBtn.disabled = false;
87
+ return showError('select-error', data.error || 'Purchase failed. Try again.');
88
+ }
89
+
90
+ currentReference = data.reference;
91
+ startPolling(data.reference);
92
+ } catch (err) {
93
+ showScreen('select');
94
+ payBtn.disabled = false;
95
+ showError('select-error', 'Network error. Please try again.');
96
+ }
97
+ });
98
+ }
99
+
100
+ // ── Polling ───────────────────────────────────────────────────────────────
101
+ function startPolling(reference) {
102
+ let attempts = 0;
103
+ const MAX = 100; // ~5 min at 3s
104
+
105
+ clearInterval(pollTimer);
106
+ pollTimer = setInterval(async () => {
107
+ attempts++;
108
+ if (attempts > MAX) {
109
+ clearInterval(pollTimer);
110
+ showScreen('select');
111
+ if (payBtn) payBtn.disabled = false;
112
+ showError('select-error', 'Payment timed out. Please try again.');
113
+ return;
114
+ }
115
+
116
+ try {
117
+ const res = await fetch(`/portal/${P.siteId}/check/${reference}`);
118
+ const data = await res.json();
119
+
120
+ if (data.status === 'paid' && data.code) {
121
+ clearInterval(pollTimer);
122
+ await authorizeWithCode(data.code);
123
+ } else if (data.status === 'failed') {
124
+ clearInterval(pollTimer);
125
+ showScreen('select');
126
+ if (payBtn) payBtn.disabled = false;
127
+ showError('select-error', 'Payment was declined. Please try again.');
128
+ }
129
+ } catch { /* network blip — keep polling */ }
130
+ }, 3000);
131
+ }
132
+
133
+ // Cancel button
134
+ const cancelBtn = $('cancel-btn');
135
+ if (cancelBtn) {
136
+ cancelBtn.addEventListener('click', () => {
137
+ clearInterval(pollTimer);
138
+ showScreen('select');
139
+ if (payBtn) payBtn.disabled = false;
140
+ });
141
+ }
142
+
143
+ // ── Auth with code ────────────────────────────────────────────────────────
144
+ async function authorizeWithCode(code) {
145
+ try {
146
+ const res = await fetch(`/portal/${P.siteId}/auth`, {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify({ code, clientMac: P.clientMac }),
150
+ });
151
+ const data = await res.json();
152
+
153
+ if (!res.ok || !data.success) {
154
+ showScreen('select');
155
+ if (payBtn) payBtn.disabled = false;
156
+ showError('select-error', data.error || 'Authorization failed. Enter your code manually.');
157
+ return;
158
+ }
159
+
160
+ showSuccess(data);
161
+ } catch (err) {
162
+ // Show code entry as fallback so guest can manually enter
163
+ showCodeEntry(code);
164
+ }
165
+ }
166
+
167
+ function showSuccess(data) {
168
+ const codeEl = $('success-code');
169
+ if (codeEl) codeEl.textContent = data.code || '—';
170
+
171
+ const metaEl = $('success-meta');
172
+ if (metaEl) {
173
+ const hours = data.expiresIn ? Math.ceil(data.expiresIn / 3600) : '?';
174
+ metaEl.innerHTML = `
175
+ <div class="meta-row"><span>Speed</span><strong>${data.speedDown} ↓ / ${data.speedUp} ↑</strong></div>
176
+ <div class="meta-row"><span>Valid for</span><strong>${hours < 24 ? hours + 'h' : Math.ceil(hours/24) + 'd'}</strong></div>
177
+ <div class="meta-row"><span>Reconnect?</span><strong>Enter code above on login page</strong></div>
178
+ `;
179
+ }
180
+ showScreen('success');
181
+ }
182
+
183
+ // ── Code entry (reconnect) ────────────────────────────────────────────────
184
+ const showCodeEntryLink = $('show-code-entry');
185
+ const showPlansLink = $('show-plans');
186
+ const codeInput = $('code-input');
187
+ const codeSubmitBtn = $('code-submit-btn');
188
+
189
+ if (showCodeEntryLink) {
190
+ showCodeEntryLink.addEventListener('click', () => {
191
+ clearError('code-error');
192
+ showScreen('code');
193
+ });
194
+ }
195
+ if (showPlansLink) {
196
+ showPlansLink.addEventListener('click', () => showScreen('select'));
197
+ }
198
+
199
+ function showCodeEntry(prefill) {
200
+ if (codeInput && prefill) codeInput.value = prefill;
201
+ clearError('code-error');
202
+ showScreen('code');
203
+ }
204
+
205
+ if (codeInput) {
206
+ codeInput.addEventListener('input', () => {
207
+ // Auto-insert dash after 4 chars
208
+ let v = codeInput.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
209
+ if (v.length > 4) v = v.slice(0, 4) + '-' + v.slice(4, 8);
210
+ codeInput.value = v;
211
+ });
212
+ }
213
+
214
+ if (codeSubmitBtn) {
215
+ codeSubmitBtn.addEventListener('click', async () => {
216
+ clearError('code-error');
217
+ const code = codeInput?.value?.trim();
218
+ if (!code || code.length < 9) {
219
+ return showError('code-error', 'Enter a valid 8-character code (XXXX-XXXX).');
220
+ }
221
+
222
+ codeSubmitBtn.disabled = true;
223
+ codeSubmitBtn.textContent = 'Connecting...';
224
+
225
+ try {
226
+ const res = await fetch(`/portal/${P.siteId}/auth`, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({ code, clientMac: P.clientMac }),
230
+ });
231
+ const data = await res.json();
232
+
233
+ if (!res.ok || !data.success) {
234
+ showError('code-error', data.error || 'Code not valid or expired.');
235
+ } else {
236
+ showSuccess(data);
237
+ }
238
+ } catch {
239
+ showError('code-error', 'Network error. Please try again.');
240
+ } finally {
241
+ codeSubmitBtn.disabled = false;
242
+ codeSubmitBtn.textContent = 'Connect';
243
+ }
244
+ });
245
+ }
246
+
247
+ // If clientMac is missing (portal opened in browser), warn in console only
248
+ if (!P.clientMac) {
249
+ console.warn('[WiFi Portal] No clientMac — running in preview mode');
250
+ }
251
+
252
+ })();
schema.sql ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- =============================================
2
+ -- OMADA MULTI-TENANT WIFI PLATFORM
3
+ -- Final Schema
4
+ -- =============================================
5
+
6
+ SET NAMES utf8mb4;
7
+ SET FOREIGN_KEY_CHECKS = 0;
8
+
9
+
10
+ -- =============================================
11
+ -- TABLE 1: clients
12
+ -- =============================================
13
+ DROP TABLE IF EXISTS `clients`;
14
+ CREATE TABLE `clients` (
15
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
16
+ `business_name` VARCHAR(100) NOT NULL,
17
+ `contact_name` VARCHAR(100) NOT NULL,
18
+ `email` VARCHAR(255) NOT NULL,
19
+ `phone` VARCHAR(20) NOT NULL,
20
+ `password_hash` VARCHAR(255) NOT NULL,
21
+ `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
22
+ `balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
23
+ `total_earned` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
24
+ `total_withdrawn` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
25
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
26
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
27
+ `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
28
+ UNIQUE KEY `uk_email` (`email`),
29
+ UNIQUE KEY `uk_phone` (`phone`)
30
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
31
+
32
+
33
+ -- =============================================
34
+ -- TABLE 2: client_staff
35
+ -- =============================================
36
+ DROP TABLE IF EXISTS `client_staff`;
37
+ CREATE TABLE `client_staff` (
38
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
39
+ `client_id` INT UNSIGNED NOT NULL,
40
+ `name` VARCHAR(100) NOT NULL,
41
+ `email` VARCHAR(255) NOT NULL,
42
+ `phone` VARCHAR(20) DEFAULT NULL,
43
+ `password_hash` VARCHAR(255) NOT NULL,
44
+ `role` ENUM('manager','viewer') NOT NULL DEFAULT 'viewer',
45
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
46
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
47
+ UNIQUE KEY `uk_staff_email` (`email`),
48
+ KEY `idx_staff_client` (`client_id`),
49
+ CONSTRAINT `fk_staff_client` FOREIGN KEY (`client_id`)
50
+ REFERENCES `clients`(`id`) ON DELETE CASCADE
51
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
52
+
53
+
54
+ -- =============================================
55
+ -- TABLE 3: devices
56
+ -- =============================================
57
+ DROP TABLE IF EXISTS `devices`;
58
+ CREATE TABLE `devices` (
59
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
60
+ `client_id` INT UNSIGNED NOT NULL,
61
+ `name` VARCHAR(100) NOT NULL,
62
+ `location` VARCHAR(255) DEFAULT NULL,
63
+ `omada_site_id` VARCHAR(64) DEFAULT NULL,
64
+ `omada_portal_id` VARCHAR(64) DEFAULT NULL,
65
+ `device_username` VARCHAR(50) DEFAULT NULL,
66
+ `device_password` VARCHAR(100) DEFAULT NULL,
67
+ `mac` VARCHAR(17) NOT NULL,
68
+ `model` VARCHAR(50) DEFAULT NULL,
69
+ `firmware_version` VARCHAR(100) DEFAULT NULL,
70
+ `ip` VARCHAR(45) DEFAULT NULL,
71
+ `public_ip` VARCHAR(45) DEFAULT NULL,
72
+ `portal_html_path` VARCHAR(255) DEFAULT NULL,
73
+ `status` ENUM('pending','adopting','online','offline','removed') NOT NULL DEFAULT 'pending',
74
+ `registered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
75
+ `adopted_at` TIMESTAMP NULL DEFAULT NULL,
76
+ `last_seen_at` TIMESTAMP NULL DEFAULT NULL,
77
+ `billing_status` ENUM('trial','active','suspended','cancelled') NOT NULL DEFAULT 'trial',
78
+ `billing_expires_at` TIMESTAMP NULL DEFAULT NULL,
79
+ `monthly_fee` DECIMAL(10,2) NOT NULL DEFAULT 20000.00,
80
+ `cpu_util` TINYINT UNSIGNED DEFAULT NULL,
81
+ `mem_util` TINYINT UNSIGNED DEFAULT NULL,
82
+ `client_count` SMALLINT UNSIGNED DEFAULT 0,
83
+ `guest_count` SMALLINT UNSIGNED DEFAULT 0,
84
+ `uptime_seconds` INT UNSIGNED DEFAULT 0,
85
+ `download_bytes` BIGINT UNSIGNED DEFAULT 0,
86
+ `upload_bytes` BIGINT UNSIGNED DEFAULT 0,
87
+ `needs_upgrade` TINYINT(1) NOT NULL DEFAULT 0,
88
+ UNIQUE KEY `uk_device_mac` (`mac`),
89
+ UNIQUE KEY `uk_omada_site` (`omada_site_id`),
90
+ KEY `idx_device_client` (`client_id`),
91
+ KEY `idx_device_status` (`status`),
92
+ KEY `idx_device_billing` (`billing_status`, `billing_expires_at`),
93
+ CONSTRAINT `fk_device_client` FOREIGN KEY (`client_id`)
94
+ REFERENCES `clients`(`id`)
95
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
96
+
97
+
98
+ -- =============================================
99
+ -- TABLE 4: device_payments
100
+ -- =============================================
101
+ DROP TABLE IF EXISTS `device_payments`;
102
+ CREATE TABLE `device_payments` (
103
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
104
+ `client_id` INT UNSIGNED NOT NULL,
105
+ `device_id` INT UNSIGNED NOT NULL,
106
+ `amount` DECIMAL(10,2) NOT NULL DEFAULT 20000.00,
107
+ `reference` VARCHAR(64) NOT NULL,
108
+ `snippe_payment_id` VARCHAR(100) DEFAULT NULL,
109
+ `period_start` DATE NOT NULL,
110
+ `period_end` DATE NOT NULL,
111
+ `status` ENUM('pending','completed','failed') NOT NULL DEFAULT 'pending',
112
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
113
+ `completed_at` TIMESTAMP NULL DEFAULT NULL,
114
+ UNIQUE KEY `uk_devpay_ref` (`reference`),
115
+ KEY `idx_devpay_client` (`client_id`),
116
+ KEY `idx_devpay_device` (`device_id`),
117
+ KEY `idx_devpay_status` (`status`),
118
+ CONSTRAINT `fk_devpay_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
119
+ CONSTRAINT `fk_devpay_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
120
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
121
+
122
+
123
+ -- =============================================
124
+ -- TABLE 5: device_status_log
125
+ -- =============================================
126
+ DROP TABLE IF EXISTS `device_status_log`;
127
+ CREATE TABLE `device_status_log` (
128
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
129
+ `device_id` INT UNSIGNED NOT NULL,
130
+ `old_status` VARCHAR(20) NOT NULL,
131
+ `new_status` VARCHAR(20) NOT NULL,
132
+ `reason` VARCHAR(255) DEFAULT NULL,
133
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
134
+ KEY `idx_devlog_device` (`device_id`),
135
+ KEY `idx_devlog_time` (`created_at`),
136
+ CONSTRAINT `fk_devlog_device` FOREIGN KEY (`device_id`)
137
+ REFERENCES `devices`(`id`) ON DELETE CASCADE
138
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
139
+
140
+
141
+ -- =============================================
142
+ -- TABLE 6: wifi_plans
143
+ -- =============================================
144
+ DROP TABLE IF EXISTS `wifi_plans`;
145
+ CREATE TABLE `wifi_plans` (
146
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
147
+ `client_id` INT UNSIGNED NOT NULL,
148
+ `device_id` INT UNSIGNED NOT NULL,
149
+ `name` VARCHAR(50) NOT NULL,
150
+ `duration_seconds` INT UNSIGNED NOT NULL,
151
+ `price` DECIMAL(10,2) NOT NULL,
152
+ `down_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
153
+ `down_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT '1=Kbps 2=Mbps',
154
+ `up_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
155
+ `up_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT '1=Kbps 2=Mbps',
156
+ `display_order` TINYINT UNSIGNED NOT NULL DEFAULT 0,
157
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
158
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
159
+ `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
160
+ KEY `idx_plan_client` (`client_id`),
161
+ KEY `idx_plan_device` (`device_id`),
162
+ KEY `idx_plan_active` (`device_id`, `is_active`, `display_order`),
163
+ CONSTRAINT `fk_plan_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
164
+ CONSTRAINT `fk_plan_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
165
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
166
+
167
+
168
+ -- =============================================
169
+ -- TABLE 7: payments
170
+ -- =============================================
171
+ DROP TABLE IF EXISTS `payments`;
172
+ CREATE TABLE `payments` (
173
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
174
+ `reference` VARCHAR(64) NOT NULL,
175
+ `client_id` INT UNSIGNED NOT NULL,
176
+ `device_id` INT UNSIGNED NOT NULL,
177
+ `plan_id` INT UNSIGNED NOT NULL,
178
+ `phone` VARCHAR(20) NOT NULL,
179
+ `phone_provider` ENUM('mpesa','airtel','tigo','halopesa') NOT NULL DEFAULT 'mpesa',
180
+ `gross_amount` DECIMAL(10,2) NOT NULL,
181
+ `snippe_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
182
+ `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
183
+ `commission_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
184
+ `client_credit` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
185
+ `status` ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending',
186
+ `snippe_payment_id` VARCHAR(100) DEFAULT NULL,
187
+ `access_token_code` VARCHAR(20) DEFAULT NULL,
188
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
189
+ `completed_at` TIMESTAMP NULL DEFAULT NULL,
190
+ UNIQUE KEY `uk_reference` (`reference`),
191
+ KEY `idx_pay_client` (`client_id`),
192
+ KEY `idx_pay_device` (`device_id`),
193
+ KEY `idx_pay_status` (`status`),
194
+ KEY `idx_pay_completed` (`completed_at`),
195
+ CONSTRAINT `fk_pay_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
196
+ CONSTRAINT `fk_pay_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`),
197
+ CONSTRAINT `fk_pay_plan` FOREIGN KEY (`plan_id`) REFERENCES `wifi_plans`(`id`)
198
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
199
+
200
+
201
+ -- =============================================
202
+ -- TABLE 8: access_tokens
203
+ -- =============================================
204
+ DROP TABLE IF EXISTS `access_tokens`;
205
+ CREATE TABLE `access_tokens` (
206
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
207
+ `client_id` INT UNSIGNED NOT NULL,
208
+ `device_id` INT UNSIGNED NOT NULL,
209
+ `plan_id` INT UNSIGNED NOT NULL,
210
+ `payment_id` BIGINT UNSIGNED DEFAULT NULL,
211
+ `code` VARCHAR(20) NOT NULL,
212
+ `duration_seconds` INT UNSIGNED NOT NULL,
213
+ `down_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
214
+ `down_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2,
215
+ `up_limit` SMALLINT UNSIGNED NOT NULL DEFAULT 2,
216
+ `up_unit` TINYINT UNSIGNED NOT NULL DEFAULT 2,
217
+ `status` ENUM('unused','active','expired','revoked') NOT NULL DEFAULT 'unused',
218
+ `locked_mac` VARCHAR(17) DEFAULT NULL,
219
+ `activated_at` TIMESTAMP NULL DEFAULT NULL,
220
+ `expires_at` TIMESTAMP NULL DEFAULT NULL,
221
+ `total_seconds_used` INT UNSIGNED NOT NULL DEFAULT 0,
222
+ `total_bytes_down` BIGINT UNSIGNED NOT NULL DEFAULT 0,
223
+ `total_bytes_up` BIGINT UNSIGNED NOT NULL DEFAULT 0,
224
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
225
+ UNIQUE KEY `uk_token_code` (`code`),
226
+ KEY `idx_token_client` (`client_id`),
227
+ KEY `idx_token_device` (`device_id`),
228
+ KEY `idx_token_status` (`status`),
229
+ KEY `idx_token_expires` (`status`, `expires_at`),
230
+ KEY `idx_token_payment` (`payment_id`),
231
+ CONSTRAINT `fk_token_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
232
+ CONSTRAINT `fk_token_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`),
233
+ CONSTRAINT `fk_token_plan` FOREIGN KEY (`plan_id`) REFERENCES `wifi_plans`(`id`)
234
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
235
+
236
+
237
+ -- =============================================
238
+ -- TABLE 9: sessions
239
+ -- =============================================
240
+ DROP TABLE IF EXISTS `sessions`;
241
+ CREATE TABLE `sessions` (
242
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
243
+ `access_token_id` BIGINT UNSIGNED DEFAULT NULL,
244
+ `client_id` INT UNSIGNED NOT NULL,
245
+ `device_id` INT UNSIGNED DEFAULT NULL,
246
+ `client_mac` VARCHAR(17) NOT NULL,
247
+ `client_ip` VARCHAR(45) DEFAULT NULL,
248
+ `client_name` VARCHAR(100) DEFAULT NULL,
249
+ `ssid` VARCHAR(64) DEFAULT NULL,
250
+ `started_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
251
+ `last_seen_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
252
+ `ended_at` TIMESTAMP NULL DEFAULT NULL,
253
+ `duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
254
+ `bytes_down` BIGINT UNSIGNED NOT NULL DEFAULT 0,
255
+ `bytes_up` BIGINT UNSIGNED NOT NULL DEFAULT 0,
256
+ `rssi` SMALLINT DEFAULT NULL,
257
+ `signal_level` TINYINT UNSIGNED DEFAULT NULL,
258
+ `is_active` TINYINT(1) NOT NULL DEFAULT 1,
259
+ KEY `idx_sess_token` (`access_token_id`),
260
+ KEY `idx_sess_client` (`client_id`),
261
+ KEY `idx_sess_device` (`device_id`),
262
+ KEY `idx_sess_mac` (`client_mac`),
263
+ KEY `idx_sess_active` (`is_active`, `client_id`),
264
+ KEY `idx_sess_time` (`started_at`),
265
+ CONSTRAINT `fk_sess_token` FOREIGN KEY (`access_token_id`) REFERENCES `access_tokens`(`id`),
266
+ CONSTRAINT `fk_sess_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
267
+ CONSTRAINT `fk_sess_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`)
268
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
269
+
270
+
271
+ -- =============================================
272
+ -- TABLE 10: payouts
273
+ -- =============================================
274
+ DROP TABLE IF EXISTS `payouts`;
275
+ CREATE TABLE `payouts` (
276
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
277
+ `client_id` INT UNSIGNED NOT NULL,
278
+ `amount` DECIMAL(10,2) NOT NULL,
279
+ `payout_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
280
+ `net_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
281
+ `destination_phone` VARCHAR(20) NOT NULL,
282
+ `destination_provider` ENUM('mpesa','airtel','tigo','halopesa') NOT NULL DEFAULT 'mpesa',
283
+ `status` ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
284
+ `snippe_disbursement_id` VARCHAR(100) DEFAULT NULL,
285
+ `failure_reason` VARCHAR(255) DEFAULT NULL,
286
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
287
+ `completed_at` TIMESTAMP NULL DEFAULT NULL,
288
+ KEY `idx_payout_client` (`client_id`),
289
+ KEY `idx_payout_status` (`status`),
290
+ CONSTRAINT `fk_payout_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`)
291
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
292
+
293
+
294
+ -- =============================================
295
+ -- TABLE 11: balance_ledger
296
+ -- =============================================
297
+ DROP TABLE IF EXISTS `balance_ledger`;
298
+ CREATE TABLE `balance_ledger` (
299
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
300
+ `client_id` INT UNSIGNED NOT NULL,
301
+ `type` ENUM('wifi_sale','payout','payout_reversal','adjustment','refund') NOT NULL,
302
+ `amount` DECIMAL(10,2) NOT NULL,
303
+ `balance_after` DECIMAL(12,2) NOT NULL,
304
+ `reference_type` ENUM('payment','payout','manual') NOT NULL,
305
+ `reference_id` BIGINT UNSIGNED NOT NULL,
306
+ `description` VARCHAR(255) DEFAULT NULL,
307
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
308
+ KEY `idx_ledger_client` (`client_id`),
309
+ KEY `idx_ledger_time` (`created_at`),
310
+ KEY `idx_ledger_ref` (`reference_type`, `reference_id`),
311
+ CONSTRAINT `fk_ledger_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`)
312
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
313
+
314
+
315
+ -- =============================================
316
+ -- TABLE 12: omada_alerts
317
+ -- =============================================
318
+ DROP TABLE IF EXISTS `omada_alerts`;
319
+ CREATE TABLE `omada_alerts` (
320
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
321
+ `client_id` INT UNSIGNED NOT NULL,
322
+ `device_id` INT UNSIGNED DEFAULT NULL,
323
+ `omada_alert_id` VARCHAR(64) NOT NULL,
324
+ `alert_key` VARCHAR(50) NOT NULL,
325
+ `module` VARCHAR(50) DEFAULT NULL,
326
+ `level` ENUM('info','warning','error','critical') NOT NULL DEFAULT 'info',
327
+ `content` TEXT NOT NULL,
328
+ `device_mac` VARCHAR(17) DEFAULT NULL,
329
+ `resolved` TINYINT(1) NOT NULL DEFAULT 0,
330
+ `alert_time` TIMESTAMP NOT NULL,
331
+ `fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
332
+ UNIQUE KEY `uk_omada_alert` (`omada_alert_id`),
333
+ KEY `idx_alert_client` (`client_id`),
334
+ KEY `idx_alert_device` (`device_id`),
335
+ KEY `idx_alert_unresolved` (`client_id`, `resolved`),
336
+ CONSTRAINT `fk_alert_client` FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`),
337
+ CONSTRAINT `fk_alert_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON DELETE SET NULL
338
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
339
+
340
+
341
+ -- =============================================
342
+ -- TABLE 13: sms_log
343
+ -- =============================================
344
+ DROP TABLE IF EXISTS `sms_log`;
345
+ CREATE TABLE `sms_log` (
346
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
347
+ `phone` VARCHAR(20) NOT NULL,
348
+ `message` VARCHAR(320) NOT NULL,
349
+ `status` ENUM('sent','delivered','failed') NOT NULL,
350
+ `gateway_id` VARCHAR(100) DEFAULT NULL,
351
+ `error` VARCHAR(255) DEFAULT NULL,
352
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
353
+ `delivered_at` TIMESTAMP NULL DEFAULT NULL,
354
+ KEY `idx_sms_phone` (`phone`),
355
+ KEY `idx_sms_status` (`status`),
356
+ KEY `idx_sms_time` (`created_at`),
357
+ KEY `idx_sms_gateway` (`gateway_id`)
358
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
359
+
360
+
361
+ -- =============================================
362
+ -- TRIGGERS
363
+ -- =============================================
364
+
365
+ DROP TRIGGER IF EXISTS `trg_payment_completed`;
366
+ DELIMITER $$
367
+ CREATE TRIGGER `trg_payment_completed`
368
+ BEFORE UPDATE ON `payments`
369
+ FOR EACH ROW
370
+ BEGIN
371
+ DECLARE v_new_balance DECIMAL(12,2);
372
+
373
+ IF OLD.status = 'pending' AND NEW.status = 'completed' THEN
374
+ SET NEW.snippe_fee = ROUND(NEW.gross_amount * 0.005, 2);
375
+ SET NEW.commission_amount = ROUND(NEW.gross_amount * NEW.commission_rate, 2);
376
+ SET NEW.client_credit = NEW.gross_amount - NEW.snippe_fee - NEW.commission_amount;
377
+ SET NEW.completed_at = CURRENT_TIMESTAMP;
378
+
379
+ UPDATE `clients`
380
+ SET `balance` = `balance` + NEW.client_credit,
381
+ `total_earned` = `total_earned` + NEW.client_credit
382
+ WHERE `id` = NEW.client_id;
383
+
384
+ SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = NEW.client_id);
385
+
386
+ INSERT INTO `balance_ledger`
387
+ (`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
388
+ VALUES
389
+ (NEW.client_id, 'wifi_sale', NEW.client_credit, v_new_balance, 'payment', NEW.id,
390
+ CONCAT('WiFi sale: ', NEW.gross_amount, ' TZS (fee: ', NEW.snippe_fee, ', comm: ', NEW.commission_amount, ')'));
391
+ END IF;
392
+ END$$
393
+ DELIMITER ;
394
+
395
+
396
+ DROP TRIGGER IF EXISTS `trg_payout_requested`;
397
+ DELIMITER $$
398
+ CREATE TRIGGER `trg_payout_requested`
399
+ AFTER INSERT ON `payouts`
400
+ FOR EACH ROW
401
+ BEGIN
402
+ DECLARE v_new_balance DECIMAL(12,2);
403
+
404
+ IF NEW.status = 'pending' THEN
405
+ UPDATE `clients`
406
+ SET `balance` = `balance` - NEW.amount
407
+ WHERE `id` = NEW.client_id;
408
+
409
+ SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = NEW.client_id);
410
+
411
+ INSERT INTO `balance_ledger`
412
+ (`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
413
+ VALUES
414
+ (NEW.client_id, 'payout', -NEW.amount, v_new_balance, 'payout', NEW.id,
415
+ CONCAT('Payout request: ', NEW.amount, ' TZS to ', NEW.destination_phone));
416
+ END IF;
417
+ END$$
418
+ DELIMITER ;
419
+
420
+
421
+ DROP TRIGGER IF EXISTS `trg_payout_failed`;
422
+ DELIMITER $$
423
+ CREATE TRIGGER `trg_payout_failed`
424
+ BEFORE UPDATE ON `payouts`
425
+ FOR EACH ROW
426
+ BEGIN
427
+ DECLARE v_new_balance DECIMAL(12,2);
428
+
429
+ IF OLD.status = 'pending' AND NEW.status = 'failed' THEN
430
+ UPDATE `clients`
431
+ SET `balance` = `balance` + OLD.amount
432
+ WHERE `id` = OLD.client_id;
433
+
434
+ SET v_new_balance = (SELECT `balance` FROM `clients` WHERE `id` = OLD.client_id);
435
+
436
+ INSERT INTO `balance_ledger`
437
+ (`client_id`, `type`, `amount`, `balance_after`, `reference_type`, `reference_id`, `description`)
438
+ VALUES
439
+ (OLD.client_id, 'payout_reversal', OLD.amount, v_new_balance, 'payout', OLD.id,
440
+ CONCAT('Payout failed: ', IFNULL(NEW.failure_reason, 'unknown')));
441
+ END IF;
442
+ END$$
443
+ DELIMITER ;
444
+
445
+
446
+ DROP TRIGGER IF EXISTS `trg_payout_completed`;
447
+ DELIMITER $$
448
+ CREATE TRIGGER `trg_payout_completed`
449
+ BEFORE UPDATE ON `payouts`
450
+ FOR EACH ROW
451
+ BEGIN
452
+ IF OLD.status IN ('pending','processing') AND NEW.status = 'completed' THEN
453
+ SET NEW.completed_at = CURRENT_TIMESTAMP;
454
+
455
+ UPDATE `clients`
456
+ SET `total_withdrawn` = `total_withdrawn` + OLD.amount
457
+ WHERE `id` = OLD.client_id;
458
+ END IF;
459
+ END$$
460
+ DELIMITER ;
461
+
462
+
463
+ -- =============================================
464
+ -- TABLE 14: device_portal_settings
465
+ -- =============================================
466
+ DROP TABLE IF EXISTS `device_portal_settings`;
467
+ CREATE TABLE `device_portal_settings` (
468
+ `device_id` INT UNSIGNED NOT NULL PRIMARY KEY,
469
+ `display_name` VARCHAR(100) DEFAULT NULL COMMENT 'Overrides business_name on the portal',
470
+ `welcome_text` VARCHAR(255) DEFAULT NULL COMMENT 'Tagline shown under the name',
471
+ `logo_url` VARCHAR(500) DEFAULT NULL COMMENT 'Full URL to logo image',
472
+ `support_phone` VARCHAR(20) DEFAULT NULL COMMENT 'Support number shown to guests',
473
+ `primary_color` VARCHAR(7) NOT NULL DEFAULT '#4361ee' COMMENT 'Buttons and accents',
474
+ `bg_color` VARCHAR(7) NOT NULL DEFAULT '#1a1a2e' COMMENT 'Page background',
475
+ `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
476
+ CONSTRAINT `fk_portal_settings_device`
477
+ FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON DELETE CASCADE
478
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
479
+
480
+
481
+ -- =============================================
482
+ -- TABLE 15: password_resets
483
+ -- =============================================
484
+ DROP TABLE IF EXISTS `password_resets`;
485
+ CREATE TABLE `password_resets` (
486
+ `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
487
+ `phone` VARCHAR(20) NOT NULL,
488
+ `otp` VARCHAR(6) NOT NULL,
489
+ `expires_at` TIMESTAMP NOT NULL,
490
+ `used` TINYINT(1) NOT NULL DEFAULT 0,
491
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
492
+ KEY `idx_reset_phone` (`phone`)
493
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
494
+
495
+
496
+ -- =============================================
497
+ -- TABLE 15: system_config
498
+ -- =============================================
499
+ DROP TABLE IF EXISTS `system_config`;
500
+ CREATE TABLE `system_config` (
501
+ `key` VARCHAR(64) NOT NULL PRIMARY KEY,
502
+ `value` VARCHAR(255) NOT NULL,
503
+ `description` VARCHAR(255) DEFAULT NULL
504
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
505
+
506
+ INSERT INTO `system_config` (`key`, `value`, `description`) VALUES
507
+ ('admin_phone', '+255769590766', 'System admin phone — receives critical error alerts');
508
+
509
+
510
+ SET FOREIGN_KEY_CHECKS = 1;
src/app.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const path = require('path');
4
+
5
+ const app = express();
6
+
7
+ // ── View engine ───────────────────────────────────────────────────────────────
8
+ app.set('view engine', 'ejs');
9
+ app.set('views', path.join(__dirname, 'views', 'screens'));
10
+
11
+ // ── Static files ──────────────────────────────────────────────────────────────
12
+ app.use(express.static(path.join(__dirname, '..', 'public')));
13
+
14
+ // ── Middleware ────────────────────────────────────────────────────────────────
15
+ app.use(cors());
16
+
17
+ // NOTE: /api/webhooks/snippe needs raw body — mount it BEFORE json middleware
18
+ app.use('/api/webhooks', require('./routes/webhooks.routes'));
19
+
20
+ // JSON body parser for everything else
21
+ app.use(express.json());
22
+ app.use(express.urlencoded({ extended: false }));
23
+
24
+ // ── Routes ────────────────────────────────────────────────────────────────────
25
+
26
+ // Public captive portal
27
+ app.use('/portal', require('./routes/portal.routes'));
28
+
29
+ // Tenant API
30
+ app.use('/api/auth', require('./routes/auth.routes'));
31
+ app.use('/api/devices', require('./routes/devices.routes'));
32
+ app.use('/api/plans', require('./routes/plans.routes'));
33
+ app.use('/api/dashboard', require('./routes/dashboard.routes'));
34
+ app.use('/api/payouts', require('./routes/payouts.routes'));
35
+ app.use('/api/sessions', require('./routes/sessions.routes'));
36
+ app.use('/api/alerts', require('./routes/alerts.routes'));
37
+
38
+ // Admin API
39
+ app.use('/api/admin', require('./routes/admin.routes'));
40
+
41
+ // ── Health check ──────────────────────────────────────────────────────────────
42
+ app.get('/health', (req, res) => res.json({ status: 'ok', ts: new Date() }));
43
+
44
+ // ── 404 ───────────────────────────────────────────────────────────────────────
45
+ app.use((req, res) => res.status(404).json({ error: 'Not found' }));
46
+
47
+ // ── Error handler ─────────────────────────────────────────────────────────────
48
+ app.use((err, req, res, next) => {
49
+ console.error('[Unhandled]', err.message);
50
+ res.status(500).json({ error: 'Internal server error' });
51
+ });
52
+
53
+ module.exports = app;
src/config/db.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mysql = require('mysql2/promise');
2
+
3
+ const pool = mysql.createPool({
4
+ host: process.env.DB_HOST || 'localhost',
5
+ port: parseInt(process.env.DB_PORT) || 3306,
6
+ user: process.env.DB_USER || 'wifiuser',
7
+ password: process.env.DB_PASS || 'wifipass',
8
+ database: process.env.DB_NAME || 'wifiplatform',
9
+ waitForConnections: true,
10
+ connectionLimit: 20,
11
+ charset: 'utf8mb4',
12
+ timezone: '+00:00',
13
+ });
14
+
15
+ async function query(sql, params = []) {
16
+ const [rows] = await pool.execute(sql, params);
17
+ return rows;
18
+ }
19
+
20
+ async function queryOne(sql, params = []) {
21
+ const rows = await query(sql, params);
22
+ return rows[0] || null;
23
+ }
24
+
25
+ module.exports = { pool, query, queryOne };
src/jobs/adoptDevices.job.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const omada = require('../services/omada');
3
+
4
+ async function adoptDevices() {
5
+ try {
6
+ const pending = await db.query(`
7
+ SELECT id, mac, omada_site_id, device_username, device_password
8
+ FROM devices
9
+ WHERE status = 'pending' AND omada_site_id IS NOT NULL
10
+ `);
11
+
12
+ if (!pending.length) return;
13
+
14
+ // Fetch sites once to get deviceAccount credentials
15
+ let siteMap = {};
16
+ try {
17
+ const sites = await omada.getSites();
18
+ siteMap = Object.fromEntries(sites.map(s => [s.id, s]));
19
+ } catch (err) {
20
+ console.error('[adoptDevices] getSites failed:', err.message);
21
+ return;
22
+ }
23
+
24
+ for (const device of pending) {
25
+ const site = siteMap[device.omada_site_id];
26
+ if (!site) continue;
27
+
28
+ let omadaDevices;
29
+ try {
30
+ omadaDevices = await omada.getSiteDevices(device.omada_site_id);
31
+ } catch { continue; }
32
+
33
+ // Normalize MACs by stripping separators before comparing
34
+ // (DB may store colons/dashes; Omada always returns dashes)
35
+ const stripMac = m => m.toUpperCase().replace(/[:\-]/g, '');
36
+ const normalMac = stripMac(device.mac);
37
+ const found = omadaDevices.find(d => d.mac && stripMac(d.mac) === normalMac);
38
+
39
+ if (!found) continue; // AP not visible yet
40
+ if (found.statusCategory !== 0) {
41
+ // Already adopted or connected — advance status
42
+ if (found.statusCategory === 1) {
43
+ await db.query(
44
+ `UPDATE devices SET status = 'online', adopted_at = NOW() WHERE id = ?`,
45
+ [device.id]
46
+ );
47
+ }
48
+ continue;
49
+ }
50
+
51
+ const username = device.device_username || site.deviceAccount?.username || 'admin';
52
+ const password = device.device_password || site.deviceAccount?.password || 'Admin@123';
53
+
54
+ try {
55
+ await omada.adoptDevice(device.omada_site_id, device.mac, username, password);
56
+ await db.query(
57
+ `UPDATE devices SET status = 'adopting', adopted_at = NOW() WHERE id = ?`,
58
+ [device.id]
59
+ );
60
+ console.log(`[adoptDevices] Adopting ${device.mac}`);
61
+ } catch (err) {
62
+ console.error(`[adoptDevices] Adopt ${device.mac} failed:`, err.message);
63
+ }
64
+ }
65
+ } catch (err) {
66
+ console.error('[adoptDevices]', err.message);
67
+ }
68
+ }
69
+
70
+ module.exports = adoptDevices;
src/jobs/billingReminders.job.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const sms = require('../services/sms');
3
+ const messages = require('../utils/messages');
4
+
5
+ async function billingReminders() {
6
+ try {
7
+ // Devices expiring in the next 3 days that haven't been reminded today
8
+ const expiring = await db.query(`
9
+ SELECT d.id, d.name AS device_name,
10
+ d.billing_expires_at, d.monthly_fee,
11
+ c.phone AS client_phone, c.business_name
12
+ FROM devices d
13
+ JOIN clients c ON c.id = d.client_id
14
+ WHERE d.billing_status IN ('trial', 'active')
15
+ AND d.billing_expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 3 DAY)
16
+ `);
17
+
18
+ for (const device of expiring) {
19
+ const expiresAt = new Date(device.billing_expires_at);
20
+ const daysLeft = Math.ceil((expiresAt - Date.now()) / 86400000);
21
+
22
+ await sms.sendSMS(device.client_phone,
23
+ messages.billingReminder(device.device_name, daysLeft, device.monthly_fee)
24
+ );
25
+ }
26
+
27
+ if (expiring.length) console.log(`[billingReminders] Sent ${expiring.length} reminder(s)`);
28
+ } catch (err) {
29
+ console.error('[billingReminders]', err.message);
30
+ }
31
+ }
32
+
33
+ module.exports = billingReminders;
src/jobs/expireTokens.job.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const omada = require('../services/omada');
3
+
4
+ async function expireTokens() {
5
+ try {
6
+ // ── 1. Expire active tokens that have passed their expiry time ─────────────
7
+ const expired = await db.query(`
8
+ SELECT t.id, t.code, t.locked_mac, d.omada_site_id
9
+ FROM access_tokens t
10
+ JOIN devices d ON d.id = t.device_id
11
+ WHERE t.status = 'active' AND t.expires_at < NOW()
12
+ `);
13
+
14
+ for (const token of expired) {
15
+ // Mark expired first — prevents reconnect even if deauth fails
16
+ await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
17
+
18
+ // Deauth on Omada; if it fails the session stays is_active=1 as a retry flag
19
+ if (token.locked_mac && token.omada_site_id) {
20
+ try {
21
+ await omada.deauthorizeClient(token.omada_site_id, token.locked_mac);
22
+ await db.query(`
23
+ UPDATE sessions SET is_active = 0, ended_at = NOW()
24
+ WHERE access_token_id = ? AND is_active = 1
25
+ `, [token.id]);
26
+ } catch (err) {
27
+ console.error(`[expireTokens] Deauth ${token.code} failed (will retry):`, err.message);
28
+ // Session left is_active=1 intentionally — picked up by retry pass below
29
+ }
30
+ } else {
31
+ await db.query(`
32
+ UPDATE sessions SET is_active = 0, ended_at = NOW()
33
+ WHERE access_token_id = ? AND is_active = 1
34
+ `, [token.id]);
35
+ }
36
+ }
37
+
38
+ if (expired.length > 0) console.log(`[expireTokens] Expired ${expired.length} token(s)`);
39
+
40
+ // ── 2. Retry deauth for expired/revoked tokens whose sessions are still open ─
41
+ const pendingDeauth = await db.query(`
42
+ SELECT t.id, t.code, t.locked_mac, d.omada_site_id
43
+ FROM access_tokens t
44
+ JOIN sessions s ON s.access_token_id = t.id
45
+ JOIN devices d ON d.id = t.device_id
46
+ WHERE t.status IN ('expired', 'revoked')
47
+ AND s.is_active = 1
48
+ AND t.locked_mac IS NOT NULL
49
+ `);
50
+
51
+ for (const token of pendingDeauth) {
52
+ try {
53
+ await omada.deauthorizeClient(token.omada_site_id, token.locked_mac);
54
+ await db.query(`
55
+ UPDATE sessions SET is_active = 0, ended_at = NOW()
56
+ WHERE access_token_id = ? AND is_active = 1
57
+ `, [token.id]);
58
+ console.log(`[expireTokens] Retry deauth succeeded for ${token.code}`);
59
+ } catch (err) {
60
+ // Still down — will try again next tick
61
+ }
62
+ }
63
+ } catch (err) {
64
+ console.error('[expireTokens]', err.message);
65
+ }
66
+ }
67
+
68
+ module.exports = expireTokens;
src/jobs/index.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const cron = require('node-cron');
2
+
3
+ const expireTokens = require('./expireTokens.job');
4
+ const adoptDevices = require('./adoptDevices.job');
5
+ const pollDevices = require('./pollDevices.job');
6
+ const pollSessions = require('./pollSessions.job');
7
+ const pollAlerts = require('./pollAlerts.job');
8
+ const suspendDevices = require('./suspendDevices.job');
9
+ const billingReminders = require('./billingReminders.job');
10
+
11
+ function initJobs() {
12
+ // Every 30 seconds
13
+ cron.schedule('*/30 * * * * *', expireTokens);
14
+ cron.schedule('*/30 * * * * *', adoptDevices);
15
+
16
+ // Every 60 seconds
17
+ cron.schedule('*/60 * * * * *', pollSessions);
18
+
19
+ // Every 5 minutes
20
+ cron.schedule('*/5 * * * *', pollDevices);
21
+
22
+ // Every 15 minutes
23
+ cron.schedule('*/15 * * * *', pollAlerts);
24
+
25
+ // Daily at midnight
26
+ cron.schedule('0 0 * * *', suspendDevices);
27
+
28
+ // Daily at 9am
29
+ cron.schedule('0 9 * * *', billingReminders);
30
+
31
+ console.log('[Jobs] All cron jobs started');
32
+ }
33
+
34
+ module.exports = { initJobs };
src/jobs/pollAlerts.job.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const omada = require('../services/omada');
3
+
4
+ const LEVEL_MAP = {
5
+ Error: 'error',
6
+ Warning: 'warning',
7
+ Information: 'info',
8
+ Critical: 'critical',
9
+ };
10
+
11
+ async function pollAlerts() {
12
+ try {
13
+ const devices = await db.query(`
14
+ SELECT DISTINCT d.client_id, d.omada_site_id, d.id AS device_id, d.mac
15
+ FROM devices d
16
+ WHERE d.status IN ('online','offline')
17
+ AND d.omada_site_id IS NOT NULL
18
+ `);
19
+
20
+ for (const device of devices) {
21
+ try {
22
+ const alerts = await omada.getSiteAlerts(device.omada_site_id);
23
+
24
+ for (const alert of alerts) {
25
+ // Find matching device_id by alert MAC
26
+ let deviceId = device.device_id;
27
+ if (alert.device?.mac && alert.device.mac.toUpperCase() !== device.mac.toUpperCase()) {
28
+ const match = await db.query(
29
+ `SELECT id FROM devices WHERE mac = ? LIMIT 1`,
30
+ [alert.device.mac.toUpperCase()]
31
+ );
32
+ if (match.length) deviceId = match[0].id;
33
+ }
34
+
35
+ await db.query(`
36
+ INSERT IGNORE INTO omada_alerts
37
+ (client_id, device_id, omada_alert_id, alert_key, module, level, content, device_mac, alert_time)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000))
39
+ `, [
40
+ device.client_id,
41
+ deviceId,
42
+ alert.id,
43
+ alert.key || '',
44
+ alert.module || null,
45
+ LEVEL_MAP[alert.level] || 'info',
46
+ alert.content || '',
47
+ alert.device?.mac || null,
48
+ alert.time,
49
+ ]);
50
+ }
51
+ } catch (err) {
52
+ // ignore per-site errors
53
+ }
54
+ }
55
+ } catch (err) {
56
+ console.error('[pollAlerts]', err.message);
57
+ }
58
+ }
59
+
60
+ module.exports = pollAlerts;
src/jobs/pollDevices.job.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const omada = require('../services/omada');
3
+ const sms = require('../services/sms');
4
+ const messages = require('../utils/messages');
5
+
6
+ async function pollDevices() {
7
+ try {
8
+ const devices = await db.query(`
9
+ SELECT d.id, d.omada_site_id, d.status AS old_status,
10
+ d.name AS device_name, c.phone AS client_phone
11
+ FROM devices d
12
+ JOIN clients c ON c.id = d.client_id
13
+ WHERE d.status NOT IN ('pending', 'removed')
14
+ AND d.omada_site_id IS NOT NULL
15
+ AND d.billing_status != 'cancelled'
16
+ `);
17
+
18
+ for (const device of devices) {
19
+ try {
20
+ const omadaDevs = await omada.getSiteDevices(device.omada_site_id);
21
+ if (!omadaDevs.length) continue;
22
+
23
+ const stripMac = m => m.toUpperCase().replace(/[:\-]/g, '');
24
+ const ap = omadaDevs.find(d => d.mac && stripMac(d.mac) === stripMac(device.mac));
25
+ if (!ap) continue;
26
+
27
+ let newStatus;
28
+ if (ap.statusCategory === 1) newStatus = 'online';
29
+ else if (ap.statusCategory === 2) newStatus = 'offline';
30
+ else if (ap.statusCategory === 0) newStatus = 'adopting';
31
+ else continue;
32
+
33
+ await db.query(`
34
+ UPDATE devices SET
35
+ status = ?,
36
+ model = COALESCE(?, model),
37
+ firmware_version = COALESCE(?, firmware_version),
38
+ ip = ?,
39
+ public_ip = ?,
40
+ cpu_util = ?,
41
+ mem_util = ?,
42
+ client_count = ?,
43
+ guest_count = ?,
44
+ uptime_seconds = ?,
45
+ download_bytes = ?,
46
+ upload_bytes = ?,
47
+ needs_upgrade = ?,
48
+ last_seen_at = NOW()
49
+ WHERE id = ?
50
+ `, [
51
+ newStatus,
52
+ ap.model || null,
53
+ ap.firmwareVersion || null,
54
+ ap.ip || null,
55
+ ap.publicIp || null,
56
+ ap.cpuUtil ?? null,
57
+ ap.memUtil ?? null,
58
+ ap.clientNum || 0,
59
+ ap.guestNum || 0,
60
+ ap.uptimeLong || 0,
61
+ ap.download || 0,
62
+ ap.upload || 0,
63
+ ap.needUpgrade ? 1 : 0,
64
+ device.id,
65
+ ]);
66
+
67
+ if (newStatus !== device.old_status) {
68
+ await db.query(
69
+ `INSERT INTO device_status_log (device_id, old_status, new_status) VALUES (?, ?, ?)`,
70
+ [device.id, device.old_status, newStatus]
71
+ );
72
+
73
+ if (newStatus === 'online' && device.old_status === 'adopting') {
74
+ sms.sendSMS(device.client_phone, messages.deviceOnline(device.device_name)).catch(() => {});
75
+ } else if (newStatus === 'offline' && device.old_status === 'online') {
76
+ sms.sendSMS(device.client_phone, messages.deviceOffline(device.device_name)).catch(() => {});
77
+ }
78
+
79
+ console.log(`[pollDevices] Device ${device.id}: ${device.old_status} → ${newStatus}`);
80
+ }
81
+ } catch (err) {
82
+ // Don't let one failure stop others
83
+ if (!['timeout', 'ECONNREFUSED', 'ENOTFOUND'].some(s => err.message?.includes(s))) {
84
+ console.error(`[pollDevices] Device ${device.id}:`, err.message);
85
+ }
86
+ }
87
+ }
88
+ } catch (err) {
89
+ console.error('[pollDevices]', err.message);
90
+ }
91
+ }
92
+
93
+ module.exports = pollDevices;
src/jobs/pollSessions.job.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const omada = require('../services/omada');
3
+ const { alertAdmin } = require('../utils/adminAlert');
4
+
5
+ async function pollSessions() {
6
+ try {
7
+ const devices = await db.query(`
8
+ SELECT id, client_id, omada_site_id
9
+ FROM devices
10
+ WHERE status = 'online' AND omada_site_id IS NOT NULL
11
+ `);
12
+
13
+ for (const device of devices) {
14
+ try {
15
+ const clients = await omada.getSiteClients(device.omada_site_id);
16
+
17
+ const activeMacs = new Set(clients.map(c => c.mac?.toUpperCase()).filter(Boolean));
18
+
19
+ // Mark sessions inactive for clients no longer visible on Omada
20
+ await db.query(`
21
+ UPDATE sessions SET is_active = 0, ended_at = NOW()
22
+ WHERE device_id = ? AND is_active = 1
23
+ AND client_mac NOT IN (${activeMacs.size ? [...activeMacs].map(() => '?').join(',') : "''"})
24
+ `, [device.id, ...activeMacs]);
25
+
26
+ if (!clients.length) continue;
27
+
28
+ const macs = [...activeMacs];
29
+
30
+ // ── Batch fetch tokens and sessions in 2 queries instead of 2×N ────────
31
+ const tokens = await db.query(
32
+ `SELECT id, locked_mac FROM access_tokens
33
+ WHERE device_id = ? AND status = 'active'
34
+ AND locked_mac IN (${macs.map(() => '?').join(',')})`,
35
+ [device.id, ...macs]
36
+ );
37
+ const tokenByMac = Object.fromEntries(tokens.map(t => [t.locked_mac, t]));
38
+
39
+ const sessions = await db.query(
40
+ `SELECT id, client_mac FROM sessions
41
+ WHERE device_id = ? AND is_active = 1
42
+ AND client_mac IN (${macs.map(() => '?').join(',')})`,
43
+ [device.id, ...macs]
44
+ );
45
+ const sessionByMac = Object.fromEntries(sessions.map(s => [s.client_mac, s]));
46
+
47
+ // ── Upsert sessions ───────────────────────────────────────────────────
48
+ for (const c of clients) {
49
+ if (!c.mac) continue;
50
+ const mac = c.mac.toUpperCase();
51
+ const token = tokenByMac[mac] || null;
52
+ const existingSession = sessionByMac[mac] || null;
53
+
54
+ if (existingSession) {
55
+ await db.query(`
56
+ UPDATE sessions SET
57
+ last_seen_at = NOW(),
58
+ duration_seconds = TIMESTAMPDIFF(SECOND, started_at, NOW()),
59
+ bytes_down = ?,
60
+ bytes_up = ?,
61
+ rssi = ?,
62
+ signal_level = ?
63
+ WHERE id = ?
64
+ `, [
65
+ c.trafficDown || 0,
66
+ c.trafficUp || 0,
67
+ c.rssi || null,
68
+ c.signalLevel || null,
69
+ existingSession.id,
70
+ ]);
71
+ } else {
72
+ await db.query(`
73
+ INSERT IGNORE INTO sessions
74
+ (access_token_id, client_id, device_id, client_mac, client_ip, client_name, ssid,
75
+ bytes_down, bytes_up, rssi, signal_level)
76
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ `, [
78
+ token?.id || null,
79
+ device.client_id,
80
+ device.id,
81
+ mac,
82
+ c.ip || null,
83
+ c.name || null,
84
+ c.ssid || null,
85
+ c.trafficDown || 0,
86
+ c.trafficUp || 0,
87
+ c.rssi || null,
88
+ c.signalLevel || null,
89
+ ]);
90
+ }
91
+ }
92
+ } catch (err) {
93
+ console.error(`[pollSessions] Device ${device.id} (site ${device.omada_site_id}):`, err.message);
94
+ alertAdmin(
95
+ `pollSessions:${device.id}`,
96
+ `Session sync failed for device ${device.id} (site ${device.omada_site_id}): ${err.message}`
97
+ );
98
+ }
99
+ }
100
+ } catch (err) {
101
+ console.error('[pollSessions] Fatal:', err.message);
102
+ alertAdmin('pollSessions:fatal', `pollSessions job crashed: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ module.exports = pollSessions;
src/jobs/suspendDevices.job.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const sms = require('../services/sms');
3
+ const omada = require('../services/omada');
4
+ const messages = require('../utils/messages');
5
+
6
+ async function suspendDevices() {
7
+ try {
8
+ // Find expired trial/active devices
9
+ const expired = await db.query(`
10
+ SELECT d.id, d.name AS device_name, d.monthly_fee, d.omada_site_id, c.phone AS client_phone
11
+ FROM devices d
12
+ JOIN clients c ON c.id = d.client_id
13
+ WHERE d.billing_status IN ('trial', 'active')
14
+ AND d.billing_expires_at < NOW()
15
+ AND d.billing_status != 'suspended'
16
+ `);
17
+
18
+ for (const device of expired) {
19
+ await db.query(
20
+ `UPDATE devices SET billing_status = 'suspended' WHERE id = ?`,
21
+ [device.id]
22
+ );
23
+
24
+ // Revoke all active tokens and kick connected guests off Omada
25
+ const activeTokens = await db.query(
26
+ `SELECT id, locked_mac FROM access_tokens
27
+ WHERE device_id = ? AND status = 'active'`,
28
+ [device.id]
29
+ );
30
+
31
+ for (const token of activeTokens) {
32
+ if (token.locked_mac && device.omada_site_id) {
33
+ try {
34
+ await omada.deauthorizeClient(device.omada_site_id, token.locked_mac);
35
+ } catch (err) {
36
+ console.error(`[suspendDevices] Deauth ${token.locked_mac} failed:`, err.message);
37
+ }
38
+ }
39
+ await db.query(
40
+ `UPDATE access_tokens SET status = 'revoked' WHERE id = ?`,
41
+ [token.id]
42
+ );
43
+ await db.query(
44
+ `UPDATE sessions SET is_active = 0, ended_at = NOW()
45
+ WHERE access_token_id = ? AND is_active = 1`,
46
+ [token.id]
47
+ );
48
+ }
49
+
50
+ const fee = parseInt(device.monthly_fee || 20000).toLocaleString();
51
+ sms.sendSMS(device.client_phone, messages.deviceSuspended(device.device_name, fee)).catch(() => {});
52
+
53
+ console.log(`[suspendDevices] Suspended device ${device.id} (${device.device_name}), revoked ${activeTokens.length} token(s)`);
54
+ }
55
+
56
+ if (expired.length) console.log(`[suspendDevices] Suspended ${expired.length} device(s)`);
57
+ } catch (err) {
58
+ console.error('[suspendDevices]', err.message);
59
+ }
60
+ }
61
+
62
+ module.exports = suspendDevices;
src/middleware/auth.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const jwt = require('jsonwebtoken');
2
+ const db = require('../config/db');
3
+
4
+ async function requireAuth(req, res, next) {
5
+ const header = req.headers.authorization;
6
+ if (!header?.startsWith('Bearer ')) {
7
+ return res.status(401).json({ error: 'Authentication required' });
8
+ }
9
+ try {
10
+ const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
11
+
12
+ // Confirm account is still active — catches deactivated tenants mid-session
13
+ const client = await db.queryOne(
14
+ 'SELECT id, is_active FROM clients WHERE id = ? LIMIT 1',
15
+ [payload.id]
16
+ );
17
+ if (!client || !client.is_active) {
18
+ return res.status(401).json({ error: 'Account is inactive' });
19
+ }
20
+
21
+ req.client = payload;
22
+ next();
23
+ } catch {
24
+ return res.status(401).json({ error: 'Invalid or expired token' });
25
+ }
26
+ }
27
+
28
+ function requireAdmin(req, res, next) {
29
+ const header = req.headers.authorization;
30
+ if (!header?.startsWith('Bearer ')) {
31
+ return res.status(401).json({ error: 'Admin authentication required' });
32
+ }
33
+ const token = header.slice(7);
34
+ if (token === process.env.ADMIN_TOKEN) {
35
+ req.isAdmin = true;
36
+ return next();
37
+ }
38
+ return res.status(401).json({ error: 'Invalid admin token' });
39
+ }
40
+
41
+ function signToken(client) {
42
+ return jwt.sign(
43
+ { id: client.id, email: client.email, business_name: client.business_name },
44
+ process.env.JWT_SECRET,
45
+ { expiresIn: '30d' }
46
+ );
47
+ }
48
+
49
+ module.exports = { requireAuth, requireAdmin, signToken };
src/middleware/rateLimiter.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const rateLimit = require('express-rate-limit');
2
+
3
+ // Tenant login — 5 failed attempts per 15 min per IP
4
+ const loginLimiter = rateLimit({
5
+ windowMs: 15 * 60 * 1000,
6
+ max: 5,
7
+ skipSuccessfulRequests: true,
8
+ standardHeaders: true,
9
+ legacyHeaders: false,
10
+ message: { error: 'Too many login attempts. Try again in 15 minutes.' },
11
+ });
12
+
13
+ // Tenant registration — 3 accounts per hour per IP
14
+ const registerLimiter = rateLimit({
15
+ windowMs: 60 * 60 * 1000,
16
+ max: 3,
17
+ standardHeaders: true,
18
+ legacyHeaders: false,
19
+ message: { error: 'Too many registration attempts. Try again in an hour.' },
20
+ });
21
+
22
+ // Portal purchase — 5 per 10 min per IP (prevents M-Pesa spam to any phone)
23
+ const purchaseLimiter = rateLimit({
24
+ windowMs: 10 * 60 * 1000,
25
+ max: 5,
26
+ standardHeaders: true,
27
+ legacyHeaders: false,
28
+ message: { error: 'Too many purchase attempts. Please wait before trying again.' },
29
+ });
30
+
31
+ // Forgot password — 3 OTP requests per 15 min per IP (prevents SMS bombing)
32
+ const forgotPasswordLimiter = rateLimit({
33
+ windowMs: 15 * 60 * 1000,
34
+ max: 3,
35
+ standardHeaders: true,
36
+ legacyHeaders: false,
37
+ message: { error: 'Too many reset requests. Try again in 15 minutes.' },
38
+ });
39
+
40
+ // Portal auth / code entry — 10 per 15 min per IP (prevents code brute force)
41
+ const portalAuthLimiter = rateLimit({
42
+ windowMs: 15 * 60 * 1000,
43
+ max: 10,
44
+ standardHeaders: true,
45
+ legacyHeaders: false,
46
+ message: { error: 'Too many authorization attempts. Try again in 15 minutes.' },
47
+ });
48
+
49
+ module.exports = { loginLimiter, registerLimiter, forgotPasswordLimiter, purchaseLimiter, portalAuthLimiter };
src/routes/admin.routes.js ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const omada = require('../services/omada');
4
+ const { requireAdmin } = require('../middleware/auth');
5
+
6
+ router.use(requireAdmin);
7
+
8
+ // GET /api/admin/overview
9
+ router.get('/overview', async (req, res) => {
10
+ try {
11
+ const [[stats]] = await Promise.all([
12
+ db.query(`
13
+ SELECT
14
+ (SELECT COUNT(*) FROM clients WHERE is_active = 1) AS tenants,
15
+ (SELECT COUNT(*) FROM devices WHERE status = 'online') AS devices_online,
16
+ (SELECT COUNT(*) FROM devices WHERE status = 'offline') AS devices_offline,
17
+ (SELECT COUNT(*) FROM devices WHERE billing_status = 'suspended') AS devices_suspended,
18
+ (SELECT COUNT(*) FROM sessions WHERE is_active = 1) AS guests_online,
19
+ (SELECT COUNT(*) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS sales_today,
20
+ (SELECT COALESCE(SUM(gross_amount),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS gross_today,
21
+ (SELECT COALESCE(SUM(commission_amount),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS commission_today,
22
+ (SELECT COALESCE(SUM(snippe_fee),0) FROM payments WHERE status = 'completed' AND DATE(completed_at) = CURDATE()) AS snippe_fees_today,
23
+ (SELECT COUNT(*) FROM payouts WHERE status = 'pending') AS pending_payouts,
24
+ (SELECT COALESCE(SUM(amount),0) FROM payouts WHERE status = 'pending') AS pending_payout_total
25
+ `)
26
+ ]);
27
+
28
+ res.json(stats[0]);
29
+ } catch (err) {
30
+ console.error('[admin/overview]', err.message);
31
+ res.status(500).json({ error: 'Failed to load overview' });
32
+ }
33
+ });
34
+
35
+ // GET /api/admin/tenants
36
+ router.get('/tenants', async (req, res) => {
37
+ try {
38
+ const tenants = await db.query(`
39
+ SELECT c.id, c.business_name, c.contact_name, c.email, c.phone,
40
+ c.balance, c.total_earned, c.total_withdrawn, c.is_active, c.created_at,
41
+ COUNT(d.id) AS device_count,
42
+ SUM(CASE WHEN d.status = 'online' THEN 1 ELSE 0 END) AS devices_online
43
+ FROM clients c
44
+ LEFT JOIN devices d ON d.client_id = c.id AND d.billing_status != 'cancelled'
45
+ GROUP BY c.id
46
+ ORDER BY c.created_at DESC
47
+ `);
48
+ res.json(tenants);
49
+ } catch (err) {
50
+ res.status(500).json({ error: 'Failed to fetch tenants' });
51
+ }
52
+ });
53
+
54
+ // GET /api/admin/devices
55
+ router.get('/devices', async (req, res) => {
56
+ try {
57
+ const devices = await db.query(`
58
+ SELECT d.id, d.name, d.location, d.mac, d.model, d.status, d.billing_status,
59
+ d.billing_expires_at, d.monthly_fee, d.cpu_util, d.mem_util,
60
+ d.guest_count, d.last_seen_at,
61
+ c.business_name, c.phone AS client_phone
62
+ FROM devices d
63
+ JOIN clients c ON c.id = d.client_id
64
+ WHERE d.billing_status != 'cancelled'
65
+ ORDER BY d.status ASC, d.registered_at DESC
66
+ `);
67
+ res.json(devices);
68
+ } catch (err) {
69
+ res.status(500).json({ error: 'Failed to fetch devices' });
70
+ }
71
+ });
72
+
73
+ // GET /api/admin/payouts/pending
74
+ router.get('/payouts/pending', async (req, res) => {
75
+ try {
76
+ const payouts = await db.query(`
77
+ SELECT po.id, po.amount, po.payout_fee, po.net_amount,
78
+ po.destination_phone, po.destination_provider, po.status, po.created_at,
79
+ c.business_name, c.contact_name
80
+ FROM payouts po
81
+ JOIN clients c ON c.id = po.client_id
82
+ WHERE po.status IN ('pending', 'processing')
83
+ ORDER BY po.created_at ASC
84
+ `);
85
+ res.json(payouts);
86
+ } catch (err) {
87
+ res.status(500).json({ error: 'Failed to fetch pending payouts' });
88
+ }
89
+ });
90
+
91
+ // GET /api/admin/revenue/daily?days=30
92
+ router.get('/revenue/daily', async (req, res) => {
93
+ const days = parseInt(req.query.days) || 30;
94
+ try {
95
+ const rows = await db.query(`
96
+ SELECT DATE(completed_at) AS day,
97
+ COUNT(*) AS sales,
98
+ SUM(gross_amount) AS gross,
99
+ SUM(commission_amount) AS commission,
100
+ SUM(client_credit) AS tenant_credit
101
+ FROM payments
102
+ WHERE status = 'completed'
103
+ AND completed_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
104
+ GROUP BY DATE(completed_at)
105
+ ORDER BY day DESC
106
+ `, [days]);
107
+ res.json(rows);
108
+ } catch (err) {
109
+ res.status(500).json({ error: 'Failed to fetch revenue' });
110
+ }
111
+ });
112
+
113
+ // POST /api/admin/devices/:id/reboot
114
+ router.post('/devices/:id/reboot', async (req, res) => {
115
+ try {
116
+ const device = await db.query(
117
+ 'SELECT omada_site_id, mac FROM devices WHERE id = ? LIMIT 1',
118
+ [req.params.id]
119
+ ).then(r => r[0]);
120
+
121
+ if (!device?.omada_site_id) return res.status(404).json({ error: 'Device not found or not adopted' });
122
+
123
+ await omada.rebootDevice(device.omada_site_id, device.mac);
124
+ res.json({ message: 'Reboot command sent' });
125
+ } catch (err) {
126
+ res.status(500).json({ error: 'Reboot failed: ' + err.message });
127
+ }
128
+ });
129
+
130
+ module.exports = router;
src/routes/alerts.routes.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const { requireAuth } = require('../middleware/auth');
4
+
5
+ router.use(requireAuth);
6
+
7
+ // GET /api/alerts/unresolved
8
+ router.get('/unresolved', async (req, res) => {
9
+ try {
10
+ const alerts = await db.query(
11
+ `SELECT a.id, a.alert_key, a.level, a.content, a.device_mac,
12
+ a.alert_time, a.fetched_at,
13
+ d.name AS device_name
14
+ FROM omada_alerts a
15
+ LEFT JOIN devices d ON d.id = a.device_id
16
+ WHERE a.client_id = ? AND a.resolved = 0
17
+ ORDER BY a.alert_time DESC LIMIT 50`,
18
+ [req.client.id]
19
+ );
20
+ res.json(alerts);
21
+ } catch (err) {
22
+ res.status(500).json({ error: 'Failed to fetch alerts' });
23
+ }
24
+ });
25
+
26
+ // PATCH /api/alerts/:id/resolve
27
+ router.patch('/:id/resolve', async (req, res) => {
28
+ try {
29
+ await db.query(
30
+ 'UPDATE omada_alerts SET resolved = 1 WHERE id = ? AND client_id = ?',
31
+ [req.params.id, req.client.id]
32
+ );
33
+ res.json({ message: 'Alert resolved' });
34
+ } catch (err) {
35
+ res.status(500).json({ error: 'Failed to resolve alert' });
36
+ }
37
+ });
38
+
39
+ module.exports = router;
src/routes/auth.routes.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const bcrypt = require('bcryptjs');
3
+ const db = require('../config/db');
4
+ const sms = require('../services/sms');
5
+ const messages = require('../utils/messages');
6
+ const { requireAuth, signToken } = require('../middleware/auth');
7
+ const { loginLimiter, registerLimiter, forgotPasswordLimiter } = require('../middleware/rateLimiter');
8
+
9
+ // POST /api/auth/register
10
+ router.post('/register', registerLimiter, async (req, res) => {
11
+ const { business_name, contact_name, email, phone, password } = req.body;
12
+
13
+ if (!business_name || !contact_name || !email || !phone || !password) {
14
+ return res.status(400).json({ error: 'All fields are required' });
15
+ }
16
+ if (password.length < 8) {
17
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
18
+ }
19
+
20
+ try {
21
+ const existing = await db.query(
22
+ 'SELECT id FROM clients WHERE email = ? OR phone = ? LIMIT 1',
23
+ [email.toLowerCase(), phone]
24
+ );
25
+ if (existing.length) {
26
+ return res.status(409).json({ error: 'Email or phone already registered' });
27
+ }
28
+
29
+ const hash = await bcrypt.hash(password, 10);
30
+ const result = await db.query(
31
+ `INSERT INTO clients (business_name, contact_name, email, phone, password_hash)
32
+ VALUES (?, ?, ?, ?, ?)`,
33
+ [business_name, contact_name, email.toLowerCase(), phone, hash]
34
+ );
35
+
36
+ const client = await db.query('SELECT * FROM clients WHERE id = ?', [result.insertId])
37
+ .then(r => r[0]);
38
+
39
+ // Welcome SMS (fire-and-forget)
40
+ sms.sendSMS(phone, messages.welcome()).catch(() => {});
41
+
42
+ const token = signToken(client);
43
+ const { password_hash, ...safe } = client;
44
+ res.status(201).json({ token, client: safe });
45
+ } catch (err) {
46
+ console.error('[auth/register]', err.message);
47
+ res.status(500).json({ error: 'Registration failed' });
48
+ }
49
+ });
50
+
51
+ // POST /api/auth/login
52
+ router.post('/login', loginLimiter, async (req, res) => {
53
+ const { email, password } = req.body;
54
+ if (!email || !password) {
55
+ return res.status(400).json({ error: 'Email and password required' });
56
+ }
57
+
58
+ try {
59
+ const client = await db.query(
60
+ 'SELECT * FROM clients WHERE email = ? AND is_active = 1 LIMIT 1',
61
+ [email.toLowerCase()]
62
+ ).then(r => r[0]);
63
+
64
+ if (!client || !(await bcrypt.compare(password, client.password_hash))) {
65
+ return res.status(401).json({ error: 'Invalid credentials' });
66
+ }
67
+
68
+ const token = signToken(client);
69
+ const { password_hash, ...safe } = client;
70
+ res.json({ token, client: safe });
71
+ } catch (err) {
72
+ console.error('[auth/login]', err.message);
73
+ res.status(500).json({ error: 'Login failed' });
74
+ }
75
+ });
76
+
77
+ // POST /api/auth/forgot-password
78
+ router.post('/forgot-password', forgotPasswordLimiter, async (req, res) => {
79
+ const { phone } = req.body;
80
+ if (!phone) return res.status(400).json({ error: 'phone required' });
81
+
82
+ try {
83
+ const client = await db.queryOne(
84
+ 'SELECT id FROM clients WHERE phone = ? AND is_active = 1 LIMIT 1',
85
+ [phone]
86
+ );
87
+
88
+ // Always respond 200 — don't reveal whether phone is registered
89
+ if (!client) return res.json({ message: 'If that number is registered, an OTP has been sent.' });
90
+
91
+ const otp = String(Math.floor(100000 + Math.random() * 900000));
92
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
93
+
94
+ // Invalidate any previous unused OTPs for this phone
95
+ await db.query(`UPDATE password_resets SET used = 1 WHERE phone = ? AND used = 0`, [phone]);
96
+
97
+ await db.query(
98
+ `INSERT INTO password_resets (phone, otp, expires_at) VALUES (?, ?, ?)`,
99
+ [phone, otp, expiresAt]
100
+ );
101
+
102
+ sms.sendSMS(phone, messages.passwordResetOtp(otp)).catch(() => {});
103
+
104
+ res.json({ message: 'If that number is registered, an OTP has been sent.' });
105
+ } catch (err) {
106
+ console.error('[auth/forgot-password]', err.message);
107
+ res.status(500).json({ error: 'Request failed' });
108
+ }
109
+ });
110
+
111
+ // POST /api/auth/reset-password
112
+ router.post('/reset-password', async (req, res) => {
113
+ const { phone, otp, new_password } = req.body;
114
+
115
+ if (!phone || !otp || !new_password) {
116
+ return res.status(400).json({ error: 'phone, otp, and new_password required' });
117
+ }
118
+ if (new_password.length < 8) {
119
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
120
+ }
121
+
122
+ try {
123
+ const reset = await db.queryOne(
124
+ `SELECT id FROM password_resets
125
+ WHERE phone = ? AND otp = ? AND used = 0 AND expires_at > NOW()
126
+ ORDER BY created_at DESC LIMIT 1`,
127
+ [phone, otp]
128
+ );
129
+
130
+ if (!reset) {
131
+ return res.status(400).json({ error: 'Invalid or expired OTP' });
132
+ }
133
+
134
+ const hash = await bcrypt.hash(new_password, 10);
135
+
136
+ await db.query(`UPDATE clients SET password_hash = ? WHERE phone = ?`, [hash, phone]);
137
+ await db.query(`UPDATE password_resets SET used = 1 WHERE id = ?`, [reset.id]);
138
+
139
+ res.json({ message: 'Password reset successfully. Please log in.' });
140
+ } catch (err) {
141
+ console.error('[auth/reset-password]', err.message);
142
+ res.status(500).json({ error: 'Reset failed' });
143
+ }
144
+ });
145
+
146
+ // GET /api/auth/me
147
+ router.get('/me', requireAuth, async (req, res) => {
148
+ try {
149
+ const client = await db.query(
150
+ 'SELECT id, business_name, contact_name, email, phone, balance, total_earned, total_withdrawn, created_at FROM clients WHERE id = ?',
151
+ [req.client.id]
152
+ ).then(r => r[0]);
153
+
154
+ if (!client) return res.status(404).json({ error: 'Account not found' });
155
+ res.json(client);
156
+ } catch (err) {
157
+ res.status(500).json({ error: 'Failed to fetch profile' });
158
+ }
159
+ });
160
+
161
+ module.exports = router;
src/routes/dashboard.routes.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const { requireAuth } = require('../middleware/auth');
4
+
5
+ router.use(requireAuth);
6
+
7
+ // GET /api/dashboard/summary
8
+ router.get('/summary', async (req, res) => {
9
+ const clientId = req.client.id;
10
+ try {
11
+ // Balance
12
+ const client = await db.query(
13
+ 'SELECT balance, total_earned, total_withdrawn FROM clients WHERE id = ?',
14
+ [clientId]
15
+ ).then(r => r[0]);
16
+
17
+ // Devices with today's stats
18
+ const devices = await db.query(
19
+ `SELECT d.id, d.name, d.location, d.status, d.billing_status, d.billing_expires_at,
20
+ d.model, d.cpu_util, d.mem_util, d.guest_count, d.last_seen_at,
21
+ (SELECT COUNT(*) FROM payments
22
+ WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()
23
+ ) AS sales_today,
24
+ (SELECT COALESCE(SUM(client_credit),0) FROM payments
25
+ WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()
26
+ ) AS earned_today,
27
+ (SELECT COUNT(*) FROM sessions
28
+ WHERE device_id = d.id AND is_active = 1
29
+ ) AS guests_online
30
+ FROM devices d
31
+ WHERE d.client_id = ? AND d.billing_status != 'cancelled'
32
+ ORDER BY d.registered_at ASC`,
33
+ [clientId]
34
+ );
35
+
36
+ // Unresolved alerts count
37
+ const alertCount = await db.query(
38
+ 'SELECT COUNT(*) AS cnt FROM omada_alerts WHERE client_id = ? AND resolved = 0',
39
+ [clientId]
40
+ ).then(r => r[0].cnt);
41
+
42
+ res.json({
43
+ balance: parseFloat(client.balance),
44
+ total_earned: parseFloat(client.total_earned),
45
+ total_withdrawn: parseFloat(client.total_withdrawn),
46
+ devices,
47
+ alert_count: alertCount,
48
+ });
49
+ } catch (err) {
50
+ console.error('[dashboard/summary]', err.message);
51
+ res.status(500).json({ error: 'Failed to load dashboard' });
52
+ }
53
+ });
54
+
55
+ // GET /api/dashboard/revenue/weekly
56
+ router.get('/revenue/weekly', async (req, res) => {
57
+ const clientId = req.client.id;
58
+ try {
59
+ const rows = await db.query(
60
+ `SELECT DATE(completed_at) AS day,
61
+ COUNT(*) AS sales,
62
+ SUM(gross_amount) AS gross,
63
+ SUM(client_credit) AS net
64
+ FROM payments
65
+ WHERE client_id = ? AND status = 'completed'
66
+ AND completed_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
67
+ GROUP BY DATE(completed_at)
68
+ ORDER BY day ASC`,
69
+ [clientId]
70
+ );
71
+ res.json(rows);
72
+ } catch (err) {
73
+ res.status(500).json({ error: 'Failed to load revenue data' });
74
+ }
75
+ });
76
+
77
+ module.exports = router;
src/routes/devices.routes.js ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const omada = require('../services/omada');
4
+ const snippe = require('../services/snippe');
5
+ const sms = require('../services/sms');
6
+ const messages = require('../utils/messages');
7
+ const { requireAuth } = require('../middleware/auth');
8
+ const { v4: uuidv4 } = require('uuid');
9
+
10
+ router.use(requireAuth);
11
+
12
+ // GET /api/devices
13
+ router.get('/', async (req, res) => {
14
+ try {
15
+ const devices = await db.query(
16
+ `SELECT id, name, location, mac, model, ip, status, billing_status,
17
+ billing_expires_at, monthly_fee, cpu_util, mem_util, guest_count,
18
+ client_count, last_seen_at, registered_at, omada_site_id
19
+ FROM devices WHERE client_id = ? AND billing_status != 'cancelled'
20
+ ORDER BY registered_at ASC`,
21
+ [req.client.id]
22
+ );
23
+ res.json(devices);
24
+ } catch (err) {
25
+ res.status(500).json({ error: 'Failed to fetch devices' });
26
+ }
27
+ });
28
+
29
+ // POST /api/devices
30
+ router.post('/', async (req, res) => {
31
+ const { name, location, mac, device_username, device_password } = req.body;
32
+
33
+ if (!name || !mac) {
34
+ return res.status(400).json({ error: 'name and mac are required' });
35
+ }
36
+
37
+ // Normalize MAC: uppercase with dashes
38
+ const normalMac = mac.trim().toUpperCase()
39
+ .replace(/[:\-]/g, '-')
40
+ .replace(/(.{2})(?=.)/g, '$1-')
41
+ .slice(0, 17);
42
+
43
+ if (!/^([0-9A-F]{2}-){5}[0-9A-F]{2}$/.test(normalMac)) {
44
+ return res.status(400).json({ error: 'Invalid MAC address format (use AA-BB-CC-DD-EE-FF)' });
45
+ }
46
+
47
+ // Check MAC not already registered
48
+ const existing = await db.query('SELECT id FROM devices WHERE mac = ? LIMIT 1', [normalMac]);
49
+ if (existing.length) {
50
+ return res.status(409).json({ error: 'This MAC address is already registered' });
51
+ }
52
+
53
+ try {
54
+ const client = await db.query('SELECT * FROM clients WHERE id = ? LIMIT 1', [req.client.id])
55
+ .then(r => r[0]);
56
+
57
+ // 1. Create Omada site
58
+ const siteName = `${client.business_name} - ${name}`;
59
+ let siteId, portalId, deviceUsername, devicePassword;
60
+
61
+ try {
62
+ siteId = await omada.createSite(siteName);
63
+
64
+ // Tenant-supplied credentials take priority; fall back to Omada site defaults
65
+ const siteInfo = await omada.getSiteById(siteId);
66
+ deviceUsername = device_username || siteInfo?.deviceAccount?.username || 'admin';
67
+ devicePassword = device_password || siteInfo?.deviceAccount?.password || 'Admin@123';
68
+
69
+ // 2. Create captive portal on this site
70
+ portalId = await omada.createPortal(siteId, `${name} Portal`, []);
71
+ } catch (omadaErr) {
72
+ console.error('[devices/POST] Omada setup failed:', omadaErr.message);
73
+ // Continue — device can still be registered, Omada setup retried later
74
+ siteId = null;
75
+ portalId = null;
76
+ }
77
+
78
+ // 3. Calculate trial expiry
79
+ const trialDays = parseInt(process.env.DEVICE_TRIAL_DAYS) || 14;
80
+ const expiresAt = new Date(Date.now() + trialDays * 24 * 60 * 60 * 1000);
81
+
82
+ // 4. Insert device
83
+ const result = await db.query(
84
+ `INSERT INTO devices
85
+ (client_id, name, location, mac, omada_site_id, omada_portal_id,
86
+ device_username, device_password, billing_status, billing_expires_at, monthly_fee)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'trial', ?, ?)`,
88
+ [
89
+ req.client.id,
90
+ name,
91
+ location || null,
92
+ normalMac,
93
+ siteId,
94
+ portalId,
95
+ deviceUsername || null,
96
+ devicePassword || null,
97
+ expiresAt,
98
+ parseFloat(process.env.DEVICE_MONTHLY_FEE) || 20000,
99
+ ]
100
+ );
101
+
102
+ const device = await db.query('SELECT * FROM devices WHERE id = ?', [result.insertId])
103
+ .then(r => r[0]);
104
+
105
+ res.status(201).json({
106
+ device,
107
+ message: siteId
108
+ ? 'Device registered. Plug in your AP and wait for it to come online.'
109
+ : 'Device registered (Omada setup pending). Contact support if the AP does not come online.',
110
+ });
111
+ } catch (err) {
112
+ console.error('[devices/POST]', err.message);
113
+ res.status(500).json({ error: 'Failed to register device' });
114
+ }
115
+ });
116
+
117
+ // GET /api/devices/:id
118
+ router.get('/:id', async (req, res) => {
119
+ try {
120
+ const device = await db.query(
121
+ `SELECT d.*,
122
+ (SELECT COUNT(*) FROM payments WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()) AS sales_today,
123
+ (SELECT COALESCE(SUM(client_credit),0) FROM payments WHERE device_id = d.id AND status = 'completed' AND DATE(completed_at) = CURDATE()) AS earned_today
124
+ FROM devices d
125
+ WHERE d.id = ? AND d.client_id = ?`,
126
+ [req.params.id, req.client.id]
127
+ ).then(r => r[0]);
128
+
129
+ if (!device) return res.status(404).json({ error: 'Device not found' });
130
+
131
+ // Active guests from DB
132
+ const activeSessions = await db.query(
133
+ `SELECT s.client_mac, s.started_at, s.bytes_down, s.bytes_up,
134
+ t.expires_at, t.code, wp.name AS plan_name
135
+ FROM sessions s
136
+ LEFT JOIN access_tokens t ON t.id = s.access_token_id
137
+ LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
138
+ WHERE s.device_id = ? AND s.is_active = 1`,
139
+ [device.id]
140
+ );
141
+
142
+ res.json({ ...device, active_sessions: activeSessions });
143
+ } catch (err) {
144
+ res.status(500).json({ error: 'Failed to fetch device' });
145
+ }
146
+ });
147
+
148
+ // GET /api/devices/:id/portal — get portal customization settings
149
+ router.get('/:id/portal', async (req, res) => {
150
+ try {
151
+ const device = await db.queryOne(
152
+ 'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
153
+ [req.params.id, req.client.id]
154
+ );
155
+ if (!device) return res.status(404).json({ error: 'Device not found' });
156
+
157
+ const settings = await db.queryOne(
158
+ 'SELECT * FROM device_portal_settings WHERE device_id = ?',
159
+ [req.params.id]
160
+ );
161
+
162
+ // Return settings or defaults if not yet customized
163
+ res.json(settings || {
164
+ device_id: parseInt(req.params.id),
165
+ display_name: null,
166
+ welcome_text: null,
167
+ logo_url: null,
168
+ support_phone: null,
169
+ primary_color: '#4361ee',
170
+ bg_color: '#1a1a2e',
171
+ });
172
+ } catch (err) {
173
+ res.status(500).json({ error: 'Failed to fetch portal settings' });
174
+ }
175
+ });
176
+
177
+ // PATCH /api/devices/:id/portal — update portal customization settings
178
+ router.patch('/:id/portal', async (req, res) => {
179
+ const { display_name, welcome_text, logo_url, support_phone, primary_color, bg_color } = req.body;
180
+
181
+ const hexColor = v => !v || /^#[0-9A-Fa-f]{6}$/.test(v);
182
+ if (!hexColor(primary_color) || !hexColor(bg_color)) {
183
+ return res.status(400).json({ error: 'Colors must be a valid hex value (e.g. #4361ee)' });
184
+ }
185
+
186
+ try {
187
+ const device = await db.queryOne(
188
+ 'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
189
+ [req.params.id, req.client.id]
190
+ );
191
+ if (!device) return res.status(404).json({ error: 'Device not found' });
192
+
193
+ await db.query(
194
+ `INSERT INTO device_portal_settings
195
+ (device_id, display_name, welcome_text, logo_url, support_phone, primary_color, bg_color)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?)
197
+ ON DUPLICATE KEY UPDATE
198
+ display_name = COALESCE(VALUES(display_name), display_name),
199
+ welcome_text = COALESCE(VALUES(welcome_text), welcome_text),
200
+ logo_url = COALESCE(VALUES(logo_url), logo_url),
201
+ support_phone = COALESCE(VALUES(support_phone), support_phone),
202
+ primary_color = COALESCE(VALUES(primary_color), primary_color),
203
+ bg_color = COALESCE(VALUES(bg_color), bg_color)`,
204
+ [
205
+ req.params.id,
206
+ display_name ?? null,
207
+ welcome_text ?? null,
208
+ logo_url ?? null,
209
+ support_phone ?? null,
210
+ primary_color ?? null,
211
+ bg_color ?? null,
212
+ ]
213
+ );
214
+
215
+ const updated = await db.queryOne(
216
+ 'SELECT * FROM device_portal_settings WHERE device_id = ?',
217
+ [req.params.id]
218
+ );
219
+ res.json(updated);
220
+ } catch (err) {
221
+ console.error('[devices/portal PATCH]', err.message);
222
+ res.status(500).json({ error: 'Failed to update portal settings' });
223
+ }
224
+ });
225
+
226
+ // DELETE /api/devices/:id (pause device — reversible via renewal)
227
+ router.delete('/:id', async (req, res) => {
228
+ try {
229
+ const device = await db.query(
230
+ `SELECT d.*, c.phone AS client_phone
231
+ FROM devices d JOIN clients c ON c.id = d.client_id
232
+ WHERE d.id = ? AND d.client_id = ? LIMIT 1`,
233
+ [req.params.id, req.client.id]
234
+ ).then(r => r[0]);
235
+
236
+ if (!device) return res.status(404).json({ error: 'Device not found' });
237
+ if (device.billing_status === 'suspended') {
238
+ return res.status(400).json({ error: 'Device is already suspended' });
239
+ }
240
+
241
+ // Suspend billing
242
+ await db.query(
243
+ `UPDATE devices SET billing_status = 'suspended' WHERE id = ?`,
244
+ [device.id]
245
+ );
246
+
247
+ // Revoke active tokens and kick guests off Omada
248
+ const activeTokens = await db.query(
249
+ `SELECT id, locked_mac FROM access_tokens WHERE device_id = ? AND status = 'active'`,
250
+ [device.id]
251
+ );
252
+
253
+ for (const token of activeTokens) {
254
+ if (token.locked_mac && device.omada_site_id) {
255
+ try {
256
+ await omada.deauthorizeClient(device.omada_site_id, token.locked_mac);
257
+ } catch (err) {
258
+ console.error(`[devices/DELETE] Deauth ${token.locked_mac} failed:`, err.message);
259
+ }
260
+ }
261
+ await db.query(`UPDATE access_tokens SET status = 'revoked' WHERE id = ?`, [token.id]);
262
+ await db.query(
263
+ `UPDATE sessions SET is_active = 0, ended_at = NOW() WHERE access_token_id = ? AND is_active = 1`,
264
+ [token.id]
265
+ );
266
+ }
267
+
268
+ // Notify tenant to renew
269
+ const fee = parseInt(device.monthly_fee || 20000).toLocaleString();
270
+ sms.sendSMS(device.client_phone, messages.devicePaused(device.name, fee)).catch(() => {});
271
+
272
+ res.json({ message: 'Device paused. Renew your subscription to reactivate.' });
273
+ } catch (err) {
274
+ console.error('[devices/DELETE]', err.message);
275
+ res.status(500).json({ error: 'Failed to pause device' });
276
+ }
277
+ });
278
+
279
+ // POST /api/devices/:id/renew — initiate monthly billing payment
280
+ router.post('/:id/renew', async (req, res) => {
281
+ try {
282
+ const device = await db.query(
283
+ `SELECT d.*, c.phone AS client_phone, c.contact_name
284
+ FROM devices d JOIN clients c ON c.id = d.client_id
285
+ WHERE d.id = ? AND d.client_id = ? LIMIT 1`,
286
+ [req.params.id, req.client.id]
287
+ ).then(r => r[0]);
288
+
289
+ if (!device) return res.status(404).json({ error: 'Device not found' });
290
+ if (device.billing_status === 'cancelled') {
291
+ return res.status(400).json({ error: 'Device is cancelled' });
292
+ }
293
+
294
+ const reference = uuidv4().replace(/-/g, '').slice(0, 20);
295
+ const now = new Date();
296
+ const start = device.billing_expires_at ? new Date(device.billing_expires_at) : now;
297
+ const end = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
298
+
299
+ await db.query(
300
+ `INSERT INTO device_payments (client_id, device_id, amount, reference, period_start, period_end, status)
301
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')`,
302
+ [req.client.id, device.id, device.monthly_fee, reference, start, end]
303
+ );
304
+
305
+ // Initiate Snippe payment to tenant's phone
306
+ const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
307
+ try {
308
+ await snippe.createPayment({
309
+ reference,
310
+ amount: device.monthly_fee,
311
+ phone: device.client_phone,
312
+ webhookUrl: `${webhookBase}/api/webhooks/snippe`,
313
+ metadata: { type: 'device_renewal', device_id: String(device.id) },
314
+ });
315
+ } catch (payErr) {
316
+ console.error('[devices/renew] Snippe error:', payErr.message);
317
+ }
318
+
319
+ res.json({ reference, amount: device.monthly_fee, message: 'Check your phone for M-Pesa prompt' });
320
+ } catch (err) {
321
+ console.error('[devices/renew]', err.message);
322
+ res.status(500).json({ error: 'Failed to initiate renewal' });
323
+ }
324
+ });
325
+
326
+ module.exports = router;
src/routes/payouts.routes.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const snippe = require('../services/snippe');
4
+ const { requireAuth } = require('../middleware/auth');
5
+
6
+ router.use(requireAuth);
7
+
8
+ // GET /api/payouts
9
+ router.get('/', async (req, res) => {
10
+ try {
11
+ const payouts = await db.query(
12
+ `SELECT id, amount, payout_fee, net_amount, destination_phone,
13
+ destination_provider, status, failure_reason, created_at, completed_at
14
+ FROM payouts WHERE client_id = ?
15
+ ORDER BY created_at DESC LIMIT 50`,
16
+ [req.client.id]
17
+ );
18
+ res.json(payouts);
19
+ } catch (err) {
20
+ res.status(500).json({ error: 'Failed to fetch payouts' });
21
+ }
22
+ });
23
+
24
+ // POST /api/payouts
25
+ router.post('/', async (req, res) => {
26
+ const { amount, phone, provider = 'mpesa' } = req.body;
27
+
28
+ if (!amount || !phone) {
29
+ return res.status(400).json({ error: 'amount and phone required' });
30
+ }
31
+
32
+ const amountNum = parseFloat(amount);
33
+ if (amountNum < 5000) {
34
+ return res.status(400).json({ error: 'Minimum payout is 5,000 TZS' });
35
+ }
36
+
37
+ try {
38
+ // Check balance
39
+ const client = await db.query(
40
+ 'SELECT balance, contact_name FROM clients WHERE id = ? FOR UPDATE',
41
+ [req.client.id]
42
+ ).then(r => r[0]);
43
+
44
+ if (parseFloat(client.balance) < amountNum) {
45
+ return res.status(400).json({
46
+ error: 'Insufficient balance',
47
+ balance: parseFloat(client.balance),
48
+ });
49
+ }
50
+
51
+ // Calculate fee
52
+ let feeAmount = 1500;
53
+ try {
54
+ const feeData = await snippe.getPayoutFee(amountNum);
55
+ feeAmount = feeData?.fee_amount || 1500;
56
+ } catch { /* use default */ }
57
+
58
+ const netAmount = amountNum - feeAmount;
59
+
60
+ // Insert payout (trg_payout_requested fires — deducts balance)
61
+ const result = await db.query(
62
+ `INSERT INTO payouts
63
+ (client_id, amount, payout_fee, net_amount, destination_phone, destination_provider, status)
64
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')`,
65
+ [req.client.id, amountNum, feeAmount, netAmount, phone, provider]
66
+ );
67
+
68
+ const payoutId = result.insertId;
69
+ const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
70
+
71
+ // Call Snippe disbursement
72
+ try {
73
+ const disbResult = await snippe.createPayout({
74
+ payoutId,
75
+ amount: amountNum,
76
+ phone,
77
+ recipientName: client.contact_name,
78
+ narration: 'WiFi platform payout',
79
+ webhookUrl: `${webhookBase}/api/webhooks/snippe`,
80
+ });
81
+
82
+ // Save disbursement reference
83
+ const disbRef = disbResult?.data?.reference || disbResult?.reference || null;
84
+ if (disbRef) {
85
+ await db.query(
86
+ `UPDATE payouts SET status = 'processing', snippe_disbursement_id = ? WHERE id = ?`,
87
+ [disbRef, payoutId]
88
+ );
89
+ }
90
+ } catch (disbErr) {
91
+ console.error('[payouts/POST] Snippe disbursement error:', disbErr.message);
92
+ // Mark failed — trigger restores balance
93
+ await db.query(
94
+ `UPDATE payouts SET status = 'failed', failure_reason = ? WHERE id = ?`,
95
+ [disbErr.message.slice(0, 255), payoutId]
96
+ );
97
+ return res.status(502).json({ error: 'Payout failed. Balance restored. Try again.' });
98
+ }
99
+
100
+ res.status(201).json({
101
+ id: payoutId,
102
+ amount: amountNum,
103
+ payout_fee: feeAmount,
104
+ net_amount: netAmount,
105
+ status: 'processing',
106
+ message: 'Payout initiated. Funds will arrive shortly.',
107
+ });
108
+ } catch (err) {
109
+ console.error('[payouts/POST]', err.message);
110
+ res.status(500).json({ error: 'Payout failed' });
111
+ }
112
+ });
113
+
114
+ module.exports = router;
src/routes/plans.routes.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const { requireAuth } = require('../middleware/auth');
4
+
5
+ router.use(requireAuth);
6
+
7
+ // GET /api/plans?deviceId=
8
+ router.get('/', async (req, res) => {
9
+ const { deviceId } = req.query;
10
+ if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
11
+
12
+ try {
13
+ // Verify device belongs to client
14
+ const device = await db.query(
15
+ 'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
16
+ [deviceId, req.client.id]
17
+ ).then(r => r[0]);
18
+ if (!device) return res.status(404).json({ error: 'Device not found' });
19
+
20
+ const plans = await db.query(
21
+ `SELECT * FROM wifi_plans WHERE device_id = ? ORDER BY display_order ASC`,
22
+ [deviceId]
23
+ );
24
+ res.json(plans);
25
+ } catch (err) {
26
+ res.status(500).json({ error: 'Failed to fetch plans' });
27
+ }
28
+ });
29
+
30
+ // POST /api/plans
31
+ router.post('/', async (req, res) => {
32
+ const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
33
+
34
+ if (!device_id || !name || !duration_seconds || !price) {
35
+ return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
36
+ }
37
+
38
+ try {
39
+ const device = await db.query(
40
+ 'SELECT id FROM devices WHERE id = ? AND client_id = ? LIMIT 1',
41
+ [device_id, req.client.id]
42
+ ).then(r => r[0]);
43
+ if (!device) return res.status(404).json({ error: 'Device not found' });
44
+
45
+ const result = await db.query(
46
+ `INSERT INTO wifi_plans
47
+ (client_id, device_id, name, duration_seconds, price,
48
+ down_limit, down_unit, up_limit, up_unit, display_order)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
50
+ [
51
+ req.client.id, device_id, name,
52
+ parseInt(duration_seconds), parseFloat(price),
53
+ down_limit || 2, down_unit || 2,
54
+ up_limit || 2, up_unit || 2,
55
+ display_order || 0,
56
+ ]
57
+ );
58
+
59
+ const plan = await db.query('SELECT * FROM wifi_plans WHERE id = ?', [result.insertId]).then(r => r[0]);
60
+ res.status(201).json(plan);
61
+ } catch (err) {
62
+ res.status(500).json({ error: 'Failed to create plan' });
63
+ }
64
+ });
65
+
66
+ // PUT /api/plans/:id
67
+ router.put('/:id', async (req, res) => {
68
+ const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
69
+
70
+ try {
71
+ const plan = await db.query(
72
+ 'SELECT p.* FROM wifi_plans p JOIN devices d ON d.id = p.device_id WHERE p.id = ? AND d.client_id = ? LIMIT 1',
73
+ [req.params.id, req.client.id]
74
+ ).then(r => r[0]);
75
+ if (!plan) return res.status(404).json({ error: 'Plan not found' });
76
+
77
+ await db.query(
78
+ `UPDATE wifi_plans SET
79
+ name = COALESCE(?, name),
80
+ duration_seconds = COALESCE(?, duration_seconds),
81
+ price = COALESCE(?, price),
82
+ down_limit = COALESCE(?, down_limit),
83
+ down_unit = COALESCE(?, down_unit),
84
+ up_limit = COALESCE(?, up_limit),
85
+ up_unit = COALESCE(?, up_unit),
86
+ display_order = COALESCE(?, display_order),
87
+ is_active = COALESCE(?, is_active)
88
+ WHERE id = ?`,
89
+ [
90
+ name || null, duration_seconds || null, price || null,
91
+ down_limit || null, down_unit || null,
92
+ up_limit || null, up_unit || null,
93
+ display_order !== undefined ? display_order : null,
94
+ is_active !== undefined ? is_active : null,
95
+ req.params.id,
96
+ ]
97
+ );
98
+
99
+ const updated = await db.query('SELECT * FROM wifi_plans WHERE id = ?', [req.params.id]).then(r => r[0]);
100
+ res.json(updated);
101
+ } catch (err) {
102
+ res.status(500).json({ error: 'Failed to update plan' });
103
+ }
104
+ });
105
+
106
+ // DELETE /api/plans/:id (soft delete — deactivate)
107
+ router.delete('/:id', async (req, res) => {
108
+ try {
109
+ const plan = await db.query(
110
+ 'SELECT p.id FROM wifi_plans p JOIN devices d ON d.id = p.device_id WHERE p.id = ? AND d.client_id = ? LIMIT 1',
111
+ [req.params.id, req.client.id]
112
+ ).then(r => r[0]);
113
+ if (!plan) return res.status(404).json({ error: 'Plan not found' });
114
+
115
+ await db.query('UPDATE wifi_plans SET is_active = 0 WHERE id = ?', [req.params.id]);
116
+ res.json({ message: 'Plan deactivated' });
117
+ } catch (err) {
118
+ res.status(500).json({ error: 'Failed to delete plan' });
119
+ }
120
+ });
121
+
122
+ module.exports = router;
src/routes/portal.routes.js ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const db = require('../config/db');
5
+ const omada = require('../services/omada');
6
+ const snippe = require('../services/snippe');
7
+ const sms = require('../services/sms');
8
+ const { generateCode } = require('../utils/generateCode');
9
+ const { v4: uuidv4 } = require('uuid');
10
+ const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
11
+
12
+ // ── GET /portal/:siteId — render captive portal page ────────────────────────
13
+ router.get('/:siteId', async (req, res) => {
14
+ const { siteId } = req.params;
15
+ const { clientMac = '', apMac = '', ssidName = '' } = req.query;
16
+
17
+ try {
18
+ const device = await db.query(
19
+ `SELECT d.*, c.business_name, c.commission_rate
20
+ FROM devices d JOIN clients c ON c.id = d.client_id
21
+ WHERE d.omada_site_id = ? LIMIT 1`,
22
+ [siteId]
23
+ ).then(r => r[0]);
24
+
25
+ if (!device) {
26
+ return res.status(404).send('<h2>Portal not found</h2>');
27
+ }
28
+
29
+ // Suspended device
30
+ if (!['trial', 'active'].includes(device.billing_status)) {
31
+ return res.render('suspended', { businessName: device.business_name });
32
+ }
33
+
34
+ // Auto-reconnect: if this MAC has an active, non-expired token, re-authorize silently
35
+ if (clientMac) {
36
+ const mac = clientMac.toUpperCase().replace(/:/g, '-');
37
+ const activeToken = await db.query(
38
+ `SELECT * FROM access_tokens
39
+ WHERE device_id = ? AND locked_mac = ? AND status = 'active'
40
+ AND expires_at > NOW()
41
+ ORDER BY expires_at DESC LIMIT 1`,
42
+ [device.id, mac]
43
+ ).then(r => r[0]);
44
+
45
+ if (activeToken) {
46
+ try {
47
+ await omada.authorizeClient(siteId, mac);
48
+ await omada.applyRateLimit(siteId, mac, {
49
+ downLimit: activeToken.down_limit,
50
+ downUnit: activeToken.down_unit,
51
+ upLimit: activeToken.up_limit,
52
+ upUnit: activeToken.up_unit,
53
+ name: `Guest-${activeToken.code}`,
54
+ });
55
+ } catch (omadaErr) {
56
+ console.error('[portal/GET] Auto-reconnect Omada error:', omadaErr.message);
57
+ // Fall through to normal portal render if Omada fails
58
+ }
59
+
60
+ const secsLeft = Math.floor((new Date(activeToken.expires_at) - Date.now()) / 1000);
61
+ const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
62
+ return res.render('reconnected', {
63
+ businessName: device.business_name,
64
+ code: activeToken.code,
65
+ expiresIn: secsLeft,
66
+ speedDown: unitLabel(activeToken.down_limit, activeToken.down_unit),
67
+ speedUp: unitLabel(activeToken.up_limit, activeToken.up_unit),
68
+ });
69
+ }
70
+ }
71
+
72
+ // Get active plans
73
+ const plans = await db.query(
74
+ `SELECT id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit
75
+ FROM wifi_plans
76
+ WHERE device_id = ? AND is_active = 1
77
+ ORDER BY display_order ASC`,
78
+ [device.id]
79
+ );
80
+
81
+ // Portal customization settings (use defaults if not set)
82
+ const portalSettings = await db.queryOne(
83
+ 'SELECT * FROM device_portal_settings WHERE device_id = ?',
84
+ [device.id]
85
+ );
86
+ const customize = {
87
+ displayName: portalSettings?.display_name || device.business_name,
88
+ welcomeText: portalSettings?.welcome_text || 'Get online instantly',
89
+ logoUrl: portalSettings?.logo_url || null,
90
+ supportPhone: portalSettings?.support_phone || null,
91
+ primaryColor: portalSettings?.primary_color || '#4361ee',
92
+ bgColor: portalSettings?.bg_color || '#1a1a2e',
93
+ };
94
+
95
+ const portalData = {
96
+ siteId,
97
+ clientMac,
98
+ apMac,
99
+ ssidName,
100
+ plans,
101
+ businessName: device.business_name,
102
+ deviceName: device.name,
103
+ customize,
104
+ };
105
+
106
+ // Custom portal HTML?
107
+ if (device.portal_html_path) {
108
+ const htmlPath = path.resolve('./portals', device.portal_html_path);
109
+ if (fs.existsSync(htmlPath)) {
110
+ let html = fs.readFileSync(htmlPath, 'utf8');
111
+ const inject =
112
+ `<script>window.PORTAL=${JSON.stringify(portalData)};</script>` +
113
+ `<script src="/engine.js"></script>`;
114
+ html = html.replace('</body>', inject + '</body>');
115
+ return res.send(html);
116
+ }
117
+ }
118
+
119
+ // Default EJS template
120
+ return res.render('portal', { portal: portalData });
121
+ } catch (err) {
122
+ console.error('[portal/GET]', err.message);
123
+ res.status(500).send('<h2>Service error. Please try again.</h2>');
124
+ }
125
+ });
126
+
127
+ // ── POST /portal/:siteId/purchase — initiate payment ────────────────────────
128
+ router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
129
+ const { siteId } = req.params;
130
+ const { planId, phone } = req.body;
131
+
132
+ if (!planId || !phone) {
133
+ return res.status(400).json({ error: 'planId and phone required' });
134
+ }
135
+
136
+ try {
137
+ const device = await db.query(
138
+ `SELECT d.*, c.commission_rate
139
+ FROM devices d JOIN clients c ON c.id = d.client_id
140
+ WHERE d.omada_site_id = ? AND d.status = 'online' LIMIT 1`,
141
+ [siteId]
142
+ ).then(r => r[0]);
143
+
144
+ if (!device) return res.status(404).json({ error: 'Device not found or offline' });
145
+ if (!['trial', 'active'].includes(device.billing_status)) {
146
+ return res.status(403).json({ error: 'WiFi service unavailable — billing suspended' });
147
+ }
148
+
149
+ const plan = await db.query(
150
+ 'SELECT * FROM wifi_plans WHERE id = ? AND device_id = ? AND is_active = 1 LIMIT 1',
151
+ [planId, device.id]
152
+ ).then(r => r[0]);
153
+
154
+ if (!plan) return res.status(404).json({ error: 'Plan not found' });
155
+
156
+ const reference = uuidv4().replace(/-/g, '').slice(0, 24);
157
+
158
+ await db.query(
159
+ `INSERT INTO payments
160
+ (reference, client_id, device_id, plan_id, phone, phone_provider,
161
+ gross_amount, commission_rate, status)
162
+ VALUES (?, ?, ?, ?, ?, 'mpesa', ?, ?, 'pending')`,
163
+ [reference, device.client_id, device.id, plan.id, phone, plan.price, device.commission_rate]
164
+ );
165
+
166
+ const webhookBase = `${process.env.PORTAL_SCHEME || 'http'}://${process.env.PORTAL_HOST}`;
167
+ try {
168
+ await snippe.createPayment({
169
+ reference,
170
+ amount: plan.price,
171
+ phone,
172
+ webhookUrl: `${webhookBase}/api/webhooks/snippe`,
173
+ metadata: {
174
+ payment_type: 'wifi_purchase',
175
+ device_id: String(device.id),
176
+ plan_id: String(plan.id),
177
+ },
178
+ });
179
+ } catch (payErr) {
180
+ console.error('[portal/purchase] Snippe error:', payErr.message);
181
+ // Don't fail — webhook will confirm; guest can poll
182
+ }
183
+
184
+ res.json({ reference, status: 'pending' });
185
+ } catch (err) {
186
+ console.error('[portal/purchase]', err.message);
187
+ res.status(500).json({ error: 'Purchase failed. Please try again.' });
188
+ }
189
+ });
190
+
191
+ // ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
192
+ router.get('/:siteId/check/:reference', async (req, res) => {
193
+ try {
194
+ const payment = await db.query(
195
+ `SELECT p.status, p.access_token_code, p.gross_amount,
196
+ wp.name AS plan_name, wp.duration_seconds,
197
+ wp.down_limit, wp.down_unit, wp.up_limit, wp.up_unit
198
+ FROM payments p
199
+ JOIN wifi_plans wp ON wp.id = p.plan_id
200
+ WHERE p.reference = ? LIMIT 1`,
201
+ [req.params.reference]
202
+ ).then(r => r[0]);
203
+
204
+ if (!payment) return res.status(404).json({ error: 'Payment not found' });
205
+
206
+ if (payment.status === 'completed' && payment.access_token_code) {
207
+ return res.json({
208
+ status: 'paid',
209
+ code: payment.access_token_code,
210
+ plan_name: payment.plan_name,
211
+ });
212
+ }
213
+
214
+ if (payment.status === 'failed') {
215
+ return res.json({ status: 'failed' });
216
+ }
217
+
218
+ res.json({ status: 'pending' });
219
+ } catch (err) {
220
+ res.status(500).json({ error: 'Check failed' });
221
+ }
222
+ });
223
+
224
+ // ── POST /portal/:siteId/auth — authorize guest on WiFi ──────────────────────
225
+ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
226
+ const { siteId } = req.params;
227
+ const { code, clientMac } = req.body;
228
+
229
+ if (!code || !clientMac) {
230
+ return res.status(400).json({ error: 'code and clientMac required' });
231
+ }
232
+
233
+ const mac = clientMac.toUpperCase().replace(/:/g, '-');
234
+
235
+ try {
236
+ const token = await db.query(
237
+ `SELECT t.*, d.omada_site_id
238
+ FROM access_tokens t
239
+ JOIN devices d ON d.id = t.device_id
240
+ WHERE t.code = ? AND d.omada_site_id = ? LIMIT 1`,
241
+ [code.toUpperCase(), siteId]
242
+ ).then(r => r[0]);
243
+
244
+ if (!token) return res.status(404).json({ error: 'Invalid code' });
245
+
246
+ if (token.status === 'expired' || token.status === 'revoked') {
247
+ return res.status(410).json({ error: 'Code has expired' });
248
+ }
249
+
250
+ if (token.status === 'active') {
251
+ // Reconnect — verify same MAC
252
+ if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
253
+ return res.status(403).json({ error: 'Code is locked to another device' });
254
+ }
255
+ // Check not expired
256
+ if (token.expires_at && new Date(token.expires_at) < new Date()) {
257
+ await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
258
+ return res.status(410).json({ error: 'Code has expired' });
259
+ }
260
+ }
261
+
262
+ if (token.status !== 'unused' && token.status !== 'active') {
263
+ return res.status(400).json({ error: 'Code cannot be used' });
264
+ }
265
+
266
+ // Authorize on Omada
267
+ try {
268
+ await omada.authorizeClient(siteId, mac);
269
+ await omada.applyRateLimit(siteId, mac, {
270
+ downLimit: token.down_limit,
271
+ downUnit: token.down_unit,
272
+ upLimit: token.up_limit,
273
+ upUnit: token.up_unit,
274
+ name: `Guest-${token.code}`,
275
+ });
276
+ } catch (omadaErr) {
277
+ console.error('[portal/auth] Omada auth failed:', omadaErr.message);
278
+ return res.status(503).json({ error: 'Authorization failed. The AP may be offline.' });
279
+ }
280
+
281
+ // Activate token
282
+ const now = new Date();
283
+ const expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
284
+
285
+ await db.query(
286
+ `UPDATE access_tokens SET
287
+ status = 'active',
288
+ locked_mac = ?,
289
+ activated_at = ?,
290
+ expires_at = ?
291
+ WHERE id = ?`,
292
+ [mac, now, expiresAt, token.id]
293
+ );
294
+
295
+ // Upsert session
296
+ const existingSession = await db.query(
297
+ 'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? LIMIT 1',
298
+ [token.id, mac]
299
+ ).then(r => r[0]);
300
+
301
+ if (!existingSession) {
302
+ await db.query(
303
+ `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac)
304
+ VALUES (?, ?, ?, ?)`,
305
+ [token.id, token.client_id, token.device_id, mac]
306
+ );
307
+ }
308
+
309
+ const unitLabel = (v, u) => `${v}${u === 2 ? 'Mbps' : 'Kbps'}`;
310
+ const secsLeft = Math.floor((expiresAt - now) / 1000);
311
+
312
+ res.json({
313
+ success: true,
314
+ code: token.code,
315
+ expiresIn: secsLeft,
316
+ expiresAt: expiresAt.toISOString(),
317
+ speedDown: unitLabel(token.down_limit, token.down_unit),
318
+ speedUp: unitLabel(token.up_limit, token.up_unit),
319
+ });
320
+ } catch (err) {
321
+ console.error('[portal/auth]', err.message);
322
+ res.status(500).json({ error: 'Authorization failed' });
323
+ }
324
+ });
325
+
326
+ module.exports = router;
src/routes/sessions.routes.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const { requireAuth } = require('../middleware/auth');
4
+
5
+ router.use(requireAuth);
6
+
7
+ // GET /api/sessions/active
8
+ router.get('/active', async (req, res) => {
9
+ try {
10
+ const sessions = await db.query(
11
+ `SELECT s.id, s.client_mac, s.client_ip, s.ssid, s.started_at,
12
+ s.last_seen_at, s.bytes_down, s.bytes_up, s.duration_seconds,
13
+ s.rssi, s.signal_level,
14
+ d.name AS device_name,
15
+ t.code AS token_code, t.expires_at,
16
+ wp.name AS plan_name
17
+ FROM sessions s
18
+ JOIN devices d ON d.id = s.device_id
19
+ LEFT JOIN access_tokens t ON t.id = s.access_token_id
20
+ LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
21
+ WHERE s.client_id = ? AND s.is_active = 1
22
+ ORDER BY s.started_at DESC`,
23
+ [req.client.id]
24
+ );
25
+ res.json(sessions);
26
+ } catch (err) {
27
+ res.status(500).json({ error: 'Failed to fetch sessions' });
28
+ }
29
+ });
30
+
31
+ // GET /api/sessions?deviceId=&limit=
32
+ router.get('/', async (req, res) => {
33
+ const { deviceId, limit = 50 } = req.query;
34
+ try {
35
+ let sql = `
36
+ SELECT s.id, s.client_mac, s.started_at, s.ended_at,
37
+ s.bytes_down, s.bytes_up, s.duration_seconds, s.is_active,
38
+ d.name AS device_name,
39
+ t.code AS token_code, wp.name AS plan_name, wp.price AS plan_price
40
+ FROM sessions s
41
+ JOIN devices d ON d.id = s.device_id
42
+ LEFT JOIN access_tokens t ON t.id = s.access_token_id
43
+ LEFT JOIN wifi_plans wp ON wp.id = t.plan_id
44
+ WHERE s.client_id = ?`;
45
+ const params = [req.client.id];
46
+
47
+ if (deviceId) {
48
+ sql += ' AND s.device_id = ?';
49
+ params.push(deviceId);
50
+ }
51
+
52
+ sql += ` ORDER BY s.started_at DESC LIMIT ?`;
53
+ params.push(parseInt(limit) || 50);
54
+
55
+ const sessions = await db.query(sql, params);
56
+ res.json(sessions);
57
+ } catch (err) {
58
+ res.status(500).json({ error: 'Failed to fetch sessions' });
59
+ }
60
+ });
61
+
62
+ module.exports = router;
src/routes/webhooks.routes.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const snippe = require('../services/snippe');
4
+ const sms = require('../services/sms');
5
+ const messages = require('../utils/messages');
6
+ const { generateCode } = require('../utils/generateCode');
7
+
8
+ // Raw body needed for signature verification
9
+ router.use(require('express').raw({ type: 'application/json' }));
10
+
11
+ // POST /api/webhooks/snippe
12
+ router.post('/snippe', async (req, res) => {
13
+ let event;
14
+ try {
15
+ event = snippe.verifyWebhookSignature(req.body.toString(), req.headers);
16
+ } catch (err) {
17
+ console.error('[webhook/snippe] Signature invalid:', err.message);
18
+ return res.status(400).json({ error: 'Invalid signature' });
19
+ }
20
+
21
+ // Respond immediately
22
+ res.status(200).json({ received: true });
23
+
24
+ // Process async
25
+ processSnippeEvent(event).catch(err =>
26
+ console.error('[webhook/snippe] Processing error:', err.message)
27
+ );
28
+ });
29
+
30
+ async function processSnippeEvent(event) {
31
+ const { type, data } = event;
32
+
33
+ switch (type) {
34
+ case 'payment.completed':
35
+ await handlePaymentCompleted(data);
36
+ break;
37
+ case 'payment.failed':
38
+ await handlePaymentFailed(data);
39
+ break;
40
+ case 'payout.completed':
41
+ await handlePayoutCompleted(data);
42
+ break;
43
+ case 'payout.failed':
44
+ await handlePayoutFailed(data);
45
+ break;
46
+ default:
47
+ console.log(`[webhook/snippe] Unhandled event: ${type}`);
48
+ }
49
+ }
50
+
51
+ async function handlePaymentCompleted(data) {
52
+ const reference = data.reference || data.external_reference;
53
+ if (!reference) return;
54
+
55
+ // ── WiFi guest purchase ────────────────────────────────────────────────────
56
+ // Atomically claim the payment — only one concurrent webhook can advance it
57
+ const claim = await db.query(
58
+ `UPDATE payments SET status = 'processing' WHERE reference = ? AND status = 'pending'`,
59
+ [reference]
60
+ );
61
+
62
+ if (claim.affectedRows === 1) {
63
+ const payment = await db.query(
64
+ `SELECT p.*, wp.duration_seconds,
65
+ wp.down_limit, wp.down_unit, wp.up_limit, wp.up_unit, wp.name AS plan_name,
66
+ d.name AS device_name, c.phone AS client_phone
67
+ FROM payments p
68
+ JOIN wifi_plans wp ON wp.id = p.plan_id
69
+ JOIN devices d ON d.id = p.device_id
70
+ JOIN clients c ON c.id = p.client_id
71
+ WHERE p.reference = ? LIMIT 1`,
72
+ [reference]
73
+ ).then(r => r[0]);
74
+
75
+ const code = generateCode();
76
+
77
+ // Create access token
78
+ const tokenResult = await db.query(
79
+ `INSERT INTO access_tokens
80
+ (client_id, device_id, plan_id, payment_id, code, duration_seconds,
81
+ down_limit, down_unit, up_limit, up_unit, status)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
83
+ [
84
+ payment.client_id, payment.device_id, payment.plan_id, payment.id,
85
+ code, payment.duration_seconds,
86
+ payment.down_limit, payment.down_unit, payment.up_limit, payment.up_unit,
87
+ ]
88
+ );
89
+
90
+ // Mark payment completed (trigger fires here — credits client balance)
91
+ await db.query(
92
+ `UPDATE payments SET
93
+ status = 'completed',
94
+ access_token_code = ?,
95
+ snippe_payment_id = ?
96
+ WHERE id = ?`,
97
+ [code, data.external_reference || null, payment.id]
98
+ );
99
+
100
+ // SMS to guest with code
101
+ const secs = payment.duration_seconds;
102
+ const duration = secs >= 86400
103
+ ? `${Math.round(secs / 86400)}d`
104
+ : secs >= 3600
105
+ ? `${Math.round(secs / 3600)}h`
106
+ : `${Math.round(secs / 60)}min`;
107
+ sms.sendSMS(payment.phone, messages.wifiCode(code, payment.plan_name, duration, payment.device_name)).catch(() => {});
108
+
109
+ console.log(`[webhook] WiFi payment completed: ${reference} → code ${code}`);
110
+ return;
111
+ }
112
+
113
+ // ── Device subscription renewal ───────────────────────────────────────────
114
+ const devPayment = await db.query(
115
+ `SELECT dp.*, d.billing_expires_at, d.name AS device_name, c.phone AS client_phone
116
+ FROM device_payments dp
117
+ JOIN devices d ON d.id = dp.device_id
118
+ JOIN clients c ON c.id = dp.client_id
119
+ WHERE dp.reference = ? AND dp.status = 'pending' LIMIT 1`,
120
+ [reference]
121
+ ).then(r => r[0]);
122
+
123
+ if (devPayment) {
124
+ // Extend billing by 30 days from current expiry (or now if suspended)
125
+ const base = devPayment.billing_expires_at
126
+ ? new Date(devPayment.billing_expires_at)
127
+ : new Date();
128
+ const newExpiry = new Date(base.getTime() + 30 * 24 * 60 * 60 * 1000);
129
+
130
+ await db.query(
131
+ `UPDATE devices SET billing_status = 'active', billing_expires_at = ? WHERE id = ?`,
132
+ [newExpiry, devPayment.device_id]
133
+ );
134
+
135
+ await db.query(
136
+ `UPDATE device_payments SET status = 'completed', completed_at = NOW(),
137
+ snippe_payment_id = ? WHERE id = ?`,
138
+ [data.external_reference || null, devPayment.id]
139
+ );
140
+
141
+ sms.sendSMS(devPayment.client_phone,
142
+ messages.deviceRenewal(devPayment.device_name, newExpiry.toLocaleDateString('en-TZ'))
143
+ ).catch(() => {});
144
+
145
+ console.log(`[webhook] Device renewal completed: ${reference}`);
146
+ }
147
+ }
148
+
149
+ async function handlePaymentFailed(data) {
150
+ const reference = data.reference;
151
+ if (!reference) return;
152
+
153
+ await db.query(
154
+ `UPDATE payments SET status = 'failed' WHERE reference = ? AND status = 'pending'`,
155
+ [reference]
156
+ );
157
+ await db.query(
158
+ `UPDATE device_payments SET status = 'failed' WHERE reference = ? AND status = 'pending'`,
159
+ [reference]
160
+ );
161
+
162
+ console.log(`[webhook] Payment failed: ${reference}`);
163
+ }
164
+
165
+ async function handlePayoutCompleted(data) {
166
+ const reference = data.reference;
167
+ if (!reference) return;
168
+
169
+ // trg_payout_completed trigger updates total_withdrawn
170
+ await db.query(
171
+ `UPDATE payouts SET status = 'completed', snippe_disbursement_id = ?, completed_at = NOW()
172
+ WHERE snippe_disbursement_id = ? OR id = ?
173
+ AND status IN ('pending','processing')`,
174
+ [reference, reference, 0]
175
+ );
176
+
177
+ // Better: find by disbursement id
178
+ const payout = await db.query(
179
+ `SELECT po.*, c.phone AS client_phone
180
+ FROM payouts po JOIN clients c ON c.id = po.client_id
181
+ WHERE po.snippe_disbursement_id = ? LIMIT 1`,
182
+ [reference]
183
+ ).then(r => r[0]);
184
+
185
+ if (payout) {
186
+ await db.query(
187
+ `UPDATE payouts SET status = 'completed', completed_at = NOW() WHERE id = ?`,
188
+ [payout.id]
189
+ );
190
+ sms.sendSMS(payout.client_phone, messages.payoutCompleted(payout.net_amount)).catch(() => {});
191
+ console.log(`[webhook] Payout completed: ${reference}`);
192
+ }
193
+ }
194
+
195
+ async function handlePayoutFailed(data) {
196
+ const reference = data.reference;
197
+ const payout = await db.query(
198
+ `SELECT po.*, c.phone AS client_phone
199
+ FROM payouts po JOIN clients c ON c.id = po.client_id
200
+ WHERE po.snippe_disbursement_id = ? LIMIT 1`,
201
+ [reference]
202
+ ).then(r => r[0]);
203
+
204
+ if (payout) {
205
+ // trg_payout_failed trigger restores balance
206
+ await db.query(
207
+ `UPDATE payouts SET status = 'failed', failure_reason = ? WHERE id = ?`,
208
+ [data.failure_reason || 'Provider error', payout.id]
209
+ );
210
+ sms.sendSMS(payout.client_phone, messages.payoutFailed(payout.amount)).catch(() => {});
211
+ console.log(`[webhook] Payout failed: ${reference}`);
212
+ }
213
+ }
214
+
215
+ module.exports = router;
src/server.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require('dotenv').config();
2
+
3
+ const app = require('./app');
4
+ const omada = require('./services/omada');
5
+ const { initJobs } = require('./jobs');
6
+
7
+ const PORT = process.env.PORT || 3000;
8
+
9
+ async function start() {
10
+ // 1. Boot Omada connection
11
+ try {
12
+ await omada.initOmada();
13
+ } catch (err) {
14
+ console.error('[Boot] Omada init failed:', err.message);
15
+ console.warn('[Boot] Continuing without Omada — check OMADA_URL / credentials');
16
+ }
17
+
18
+ // 2. Start background jobs
19
+ initJobs();
20
+
21
+ // 3. Listen
22
+ app.listen(PORT, () => {
23
+ console.log(`[Server] Listening on http://0.0.0.0:${PORT}`);
24
+ console.log(`[Server] Portal base: ${process.env.PORTAL_SCHEME}://${process.env.PORTAL_HOST}`);
25
+ });
26
+ }
27
+
28
+ start().catch(err => {
29
+ console.error('[Boot] Fatal error:', err.message);
30
+ process.exit(1);
31
+ });
src/services/omada.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const axios = require('axios');
2
+ const https = require('https');
3
+
4
+ // Omada uses self-signed certs on the controller
5
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
6
+
7
+ let controllerId = null;
8
+ let csrfToken = null;
9
+ let sessionCookies = '';
10
+
11
+ const client = axios.create({
12
+ httpsAgent,
13
+ timeout: 20000,
14
+ });
15
+
16
+ // ── Init ──────────────────────────────────────────────────────────────────────
17
+
18
+ async function initOmada() {
19
+ // 1. Fetch controller ID
20
+ const infoRes = await client.get(`${process.env.OMADA_URL}/api/info`);
21
+ controllerId = infoRes.data.result.omadacId;
22
+
23
+ // 2. Login
24
+ await omadaLogin();
25
+
26
+ // 3. 25-min keepalive
27
+ setInterval(keepAlive, 25 * 60 * 1000);
28
+
29
+ console.log(`[Omada] Ready. controllerId=${controllerId}`);
30
+ }
31
+
32
+ async function omadaLogin() {
33
+ const res = await client.post(
34
+ `${process.env.OMADA_URL}/${controllerId}/api/v2/login`,
35
+ { username: process.env.OMADA_USER, password: process.env.OMADA_PASS }
36
+ );
37
+ csrfToken = res.data.result.token;
38
+ const setCookie = res.headers['set-cookie'] || [];
39
+ sessionCookies = setCookie.map(c => c.split(';')[0]).join('; ');
40
+ }
41
+
42
+ async function keepAlive() {
43
+ try {
44
+ await omadaRequest('GET', '/current/users');
45
+ } catch (err) {
46
+ console.error('[Omada] Keepalive failed:', err.message);
47
+ }
48
+ }
49
+
50
+ // ── Core request helper ───────────────────────────────────────────────────────
51
+
52
+ async function omadaRequest(method, path, data = null, retried = false) {
53
+ if (!csrfToken) await omadaLogin();
54
+
55
+ try {
56
+ const res = await client({
57
+ method,
58
+ url: `${process.env.OMADA_URL}/${controllerId}/api/v2${path}`,
59
+ headers: {
60
+ 'Csrf-Token': csrfToken,
61
+ Cookie: sessionCookies,
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ data: data ?? undefined,
65
+ });
66
+
67
+ // Omada returns errorCode 0 on success; -1000 = session expired
68
+ if (res.data.errorCode === -1000 && !retried) {
69
+ await omadaLogin();
70
+ return omadaRequest(method, path, data, true);
71
+ }
72
+
73
+ if (res.data.errorCode !== 0) {
74
+ const err = new Error(res.data.msg || `Omada error ${res.data.errorCode}`);
75
+ err.errorCode = res.data.errorCode;
76
+ err.result = res.data.result;
77
+ throw err;
78
+ }
79
+
80
+ return res.data;
81
+ } catch (err) {
82
+ if (err.response?.status === 401 && !retried) {
83
+ await omadaLogin();
84
+ return omadaRequest(method, path, data, true);
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ // ── Sites ─────────────────────────────────────────────────────────────────────
91
+
92
+ async function createSite(name, scenario = 'Hotel') {
93
+ const res = await omadaRequest('POST', '/sites', {
94
+ name,
95
+ scenario,
96
+ region: 'Tanzania',
97
+ timeZone: 'Africa/Dar_es_Salaam',
98
+ });
99
+ return res.result; // siteId string
100
+ }
101
+
102
+ async function getSites() {
103
+ const res = await omadaRequest('GET', '/sites?currentPageSize=200&currentPage=1');
104
+ return res.result?.data || [];
105
+ }
106
+
107
+ async function getSiteById(siteId) {
108
+ const sites = await getSites();
109
+ return sites.find(s => s.id === siteId) || null;
110
+ }
111
+
112
+ // ── Portal ────────────────────────────────────────────────────────────────────
113
+
114
+ async function createPortal(siteId, portalName, ssidList = []) {
115
+ const serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`;
116
+ const res = await omadaRequest('POST', `/sites/${siteId}/setting/portals`, {
117
+ name: portalName,
118
+ enable: true,
119
+ httpsRedirectEnable: false,
120
+ landingPage: 1,
121
+ authType: 4,
122
+ externalPortal: {
123
+ hostType: 2,
124
+ serverUrlScheme: process.env.PORTAL_SCHEME || 'http',
125
+ serverUrl,
126
+ logout: false,
127
+ },
128
+ ssidList,
129
+ networkList: [],
130
+ pageType: 1,
131
+ portalCustomize: {
132
+ defaultLanguage: 1,
133
+ background: 2,
134
+ logoDisplay: true,
135
+ welcomeEnable: false,
136
+ termsOfServiceEnable: false,
137
+ copyrightEnable: false,
138
+ redirectionCountDownEnable: true,
139
+ advertisement: { enable: false, pictureIds: [] },
140
+ buttonText: 'Log In',
141
+ },
142
+ });
143
+ return res.result; // portalId string
144
+ }
145
+
146
+ // ── Devices ───────────────────────────────────────────────────────────────────
147
+
148
+ async function getSiteDevices(siteId) {
149
+ const res = await omadaRequest('GET',
150
+ `/sites/${siteId}/grid/devices?currentPage=1&currentPageSize=50`
151
+ );
152
+ return res.result?.data || [];
153
+ }
154
+
155
+ async function getAllAdoptedDevices() {
156
+ const res = await omadaRequest('GET',
157
+ '/grid/devices/adopted?currentPage=1&currentPageSize=200'
158
+ );
159
+ return res.result?.data || [];
160
+ }
161
+
162
+ async function adoptDevice(siteId, mac, username, password) {
163
+ return omadaRequest('POST', `/sites/${siteId}/cmd/devices/adopt`, {
164
+ mac, username, password,
165
+ });
166
+ }
167
+
168
+ async function rebootDevice(siteId, mac) {
169
+ return omadaRequest('POST', `/sites/${siteId}/cmd/devices/${mac}/reboot`, { mac });
170
+ }
171
+
172
+ // ── Clients (guests) ──────────────────────────────────────────────────────────
173
+
174
+ async function getClient(siteId, mac) {
175
+ const res = await omadaRequest('GET', `/sites/${siteId}/clients/${mac}`);
176
+ return res.result;
177
+ }
178
+
179
+ async function getSiteClients(siteId) {
180
+ const res = await omadaRequest('GET',
181
+ `/sites/${siteId}/clients?currentPage=1&currentPageSize=200&filters.active=true`
182
+ );
183
+ return res.result?.data || [];
184
+ }
185
+
186
+ async function authorizeClient(siteId, mac) {
187
+ return omadaRequest('POST', `/sites/${siteId}/cmd/clients/${mac}/auth`, {});
188
+ }
189
+
190
+ async function deauthorizeClient(siteId, mac) {
191
+ return omadaRequest('POST', `/sites/${siteId}/cmd/clients/${mac}/unauthorize`, {});
192
+ }
193
+
194
+ async function applyRateLimit(siteId, mac, { downLimit, downUnit, upLimit, upUnit, name = 'Guest' }) {
195
+ return omadaRequest('PATCH', `/sites/${siteId}/clients/${mac}`, {
196
+ name,
197
+ rateLimit: {
198
+ enable: true,
199
+ downEnable: true,
200
+ downLimit,
201
+ downUnit,
202
+ upEnable: true,
203
+ upLimit,
204
+ upUnit,
205
+ },
206
+ clientLockToApSetting: { enable: false },
207
+ ipSetting: { useFixedAddr: false },
208
+ });
209
+ }
210
+
211
+ // ── Alerts ────────────────────────────────────────────────────────────────────
212
+
213
+ async function getSiteAlerts(siteId) {
214
+ const now = Date.now();
215
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
216
+ const res = await omadaRequest('GET',
217
+ `/sites/${siteId}/logs/alerts` +
218
+ `?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` +
219
+ `&filters.resolved=false&currentPage=1&currentPageSize=50`
220
+ );
221
+ return res.result?.data || [];
222
+ }
223
+
224
+ module.exports = {
225
+ initOmada,
226
+ createSite,
227
+ getSites,
228
+ getSiteById,
229
+ createPortal,
230
+ getSiteDevices,
231
+ getAllAdoptedDevices,
232
+ adoptDevice,
233
+ rebootDevice,
234
+ getClient,
235
+ getSiteClients,
236
+ authorizeClient,
237
+ deauthorizeClient,
238
+ applyRateLimit,
239
+ getSiteAlerts,
240
+ };
src/services/sms.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const axios = require('axios');
2
+ const db = require('../config/db');
3
+
4
+ function normalizePhone(phone) {
5
+ const d = phone.replace(/\D/g, '');
6
+ if (d.startsWith('255')) return '+' + d;
7
+ if (d.startsWith('0')) return '+255' + d.slice(1);
8
+ return '+255' + d;
9
+ }
10
+
11
+ async function sendSMS(phone, message) {
12
+ const to = normalizePhone(phone);
13
+ let status = 'sent';
14
+ let gatewayId = null;
15
+ let error = null;
16
+
17
+ try {
18
+ const res = await axios.post(
19
+ process.env.SMS_GATEWAY_URL,
20
+ {
21
+ textMessage: { text: message },
22
+ phoneNumbers: [to],
23
+ withDeliveryReport: false,
24
+ },
25
+ {
26
+ auth: {
27
+ username: process.env.SMS_GATEWAY_USER,
28
+ password: process.env.SMS_GATEWAY_PASS,
29
+ },
30
+ timeout: 10000,
31
+ }
32
+ );
33
+ gatewayId = res.data?.id || null;
34
+ console.log(`[SMS] Sent to ${to}: "${message.slice(0, 40)}..."`);
35
+ } catch (err) {
36
+ status = 'failed';
37
+ error = err.message;
38
+ console.error(`[SMS] Failed to ${to}:`, err.message);
39
+ }
40
+
41
+ // Fire-and-forget log
42
+ db.query(
43
+ 'INSERT INTO sms_log (phone, message, status, gateway_id, error) VALUES (?, ?, ?, ?, ?)',
44
+ [to, message, status, gatewayId, error]
45
+ ).catch(() => {});
46
+
47
+ return status === 'sent';
48
+ }
49
+
50
+ module.exports = { sendSMS };
src/services/snippe.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require('crypto');
2
+ const axios = require('axios');
3
+
4
+ const BASE_URL = 'https://api.snippe.sh';
5
+
6
+ function ikey(prefix, id) {
7
+ return `${prefix}-${id}`.slice(0, 30);
8
+ }
9
+
10
+ function normalizePhone(phone) {
11
+ const d = phone.replace(/\D/g, '');
12
+ if (d.startsWith('255')) return d;
13
+ if (d.startsWith('0')) return '255' + d.slice(1);
14
+ return '255' + d;
15
+ }
16
+
17
+ async function snippeRequest(method, path, body = null, idempotencyKey = null) {
18
+ const headers = {
19
+ Authorization: `Bearer ${process.env.SNIPPE_API_KEY}`,
20
+ 'Content-Type': 'application/json',
21
+ };
22
+ if (idempotencyKey) {
23
+ if (idempotencyKey.length > 30) {
24
+ throw new Error(`Idempotency key too long (${idempotencyKey.length} chars, max 30)`);
25
+ }
26
+ headers['Idempotency-Key'] = idempotencyKey;
27
+ }
28
+
29
+ const res = await axios({
30
+ method,
31
+ url: `${BASE_URL}${path}`,
32
+ headers,
33
+ data: body ?? undefined,
34
+ timeout: 30000,
35
+ });
36
+ return res.data;
37
+ }
38
+
39
+ // ── Collection ────────────────────────────────────────────────────────────────
40
+
41
+ async function createPayment({ reference, amount, phone, webhookUrl, metadata = {} }) {
42
+ return snippeRequest('POST', '/v1/payments', {
43
+ payment_type: 'mobile',
44
+ details: { amount, currency: 'TZS' },
45
+ phone_number: normalizePhone(phone),
46
+ customer: {
47
+ firstname: metadata.firstname || 'Guest',
48
+ lastname: metadata.lastname || 'User',
49
+ email: metadata.email || 'guest@wifi.local',
50
+ },
51
+ webhook_url: webhookUrl,
52
+ metadata,
53
+ }, ikey('pay', reference));
54
+ }
55
+
56
+ async function getPayment(reference) {
57
+ return snippeRequest('GET', `/v1/payments/${reference}`);
58
+ }
59
+
60
+ // ── Disbursements ─────────────────────────────────────────────────────────────
61
+
62
+ async function getPayoutFee(amount) {
63
+ const res = await snippeRequest('GET', `/v1/payouts/fee?amount=${amount}`);
64
+ return res.data; // { amount, fee_amount, total_amount, currency }
65
+ }
66
+
67
+ async function createPayout({ payoutId, amount, phone, recipientName, narration, webhookUrl }) {
68
+ const feeData = await getPayoutFee(amount);
69
+ const res = await snippeRequest('POST', '/v1/payouts/send', {
70
+ amount,
71
+ channel: 'mobile',
72
+ recipient_phone: normalizePhone(phone),
73
+ recipient_name: recipientName,
74
+ narration: narration || 'WiFi platform payout',
75
+ webhook_url: webhookUrl,
76
+ metadata: { payout_id: String(payoutId) },
77
+ }, ikey('po', payoutId));
78
+ return { ...res, feeData };
79
+ }
80
+
81
+ // ── Webhook verification ──────────────────────────────────────────────────────
82
+
83
+ function verifyWebhookSignature(rawBody, headers) {
84
+ const timestamp = headers['x-webhook-timestamp'];
85
+ const receivedSig = headers['x-webhook-signature'];
86
+
87
+ if (!timestamp || !receivedSig) throw new Error('Missing webhook signature headers');
88
+
89
+ const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
90
+ if (age > 300) throw new Error('Webhook timestamp too old (possible replay attack)');
91
+
92
+ const expected = crypto
93
+ .createHmac('sha256', process.env.SNIPPE_WEBHOOK_SECRET)
94
+ .update(`${timestamp}.${rawBody}`)
95
+ .digest('hex');
96
+
97
+ if (!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expected))) {
98
+ throw new Error('Invalid webhook signature');
99
+ }
100
+
101
+ return JSON.parse(rawBody);
102
+ }
103
+
104
+ module.exports = {
105
+ createPayment,
106
+ getPayment,
107
+ getPayoutFee,
108
+ createPayout,
109
+ verifyWebhookSignature,
110
+ normalizePhone,
111
+ };
src/utils/adminAlert.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const db = require('../config/db');
2
+ const sms = require('../services/sms');
3
+
4
+ // In-memory cooldown — don't spam the same alert key more than once per 10 min
5
+ const lastSent = {};
6
+ const COOLDOWN_MS = 10 * 60 * 1000;
7
+
8
+ async function alertAdmin(key, message) {
9
+ const now = Date.now();
10
+ if (lastSent[key] && now - lastSent[key] < COOLDOWN_MS) return;
11
+ lastSent[key] = now;
12
+
13
+ try {
14
+ const row = await db.queryOne(
15
+ `SELECT value FROM system_config WHERE \`key\` = 'admin_phone'`
16
+ );
17
+ if (!row) return;
18
+ await sms.sendSMS(row.value, `[SYSTEM ALERT] ${message}`);
19
+ } catch (err) {
20
+ console.error('[adminAlert] Failed to send alert:', err.message);
21
+ }
22
+ }
23
+
24
+ module.exports = { alertAdmin };
src/utils/generateCode.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Readable code chars — no 0/O, 1/I confusion
2
+ const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
3
+
4
+ function generateCode() {
5
+ const part = (n) =>
6
+ Array.from({ length: n }, () => CHARS[Math.floor(Math.random() * CHARS.length)]).join('');
7
+ return `${part(4)}-${part(4)}`; // e.g. AB3K-9X2M
8
+ }
9
+
10
+ module.exports = { generateCode };
src/utils/messages.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SMS message templates.
3
+ * All outbound SMS text lives here — edit copy in one place.
4
+ */
5
+
6
+ module.exports = {
7
+ // Guest receives this after paying for WiFi
8
+ wifiCode: (code, planName, duration, deviceName) =>
9
+ `WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nEnter this code on the WiFi login page. Valid for ${duration} after connecting.`,
10
+
11
+ // Tenant receives this after their device subscription renews
12
+ deviceRenewal: (deviceName, expiryDate) =>
13
+ `"${deviceName}" renewed until ${expiryDate}. Keep selling!`,
14
+
15
+ // Tenant receives this when their AP comes online for the first time
16
+ deviceOnline: (deviceName) =>
17
+ `Your device "${deviceName}" is now online! Create WiFi plans to start selling.`,
18
+
19
+ // Tenant receives this when their AP goes offline
20
+ deviceOffline: (deviceName) =>
21
+ `Alert: Your device "${deviceName}" went offline. Check the AP.`,
22
+
23
+ // Tenant receives this when billing expires and device is auto-suspended
24
+ deviceSuspended: (deviceName, fee) =>
25
+ `Your device "${deviceName}" has been suspended (billing expired). Pay ${fee} TZS to reactivate.`,
26
+
27
+ // Tenant receives this when they manually pause their device
28
+ devicePaused: (deviceName, fee) =>
29
+ `Your device "${deviceName}" has been paused. Pay ${fee} TZS to reactivate and resume WiFi service.`,
30
+
31
+ // Tenant receives this on successful registration
32
+ welcome: () =>
33
+ `Welcome to WiFi Platform! Add your first device on the dashboard to start selling WiFi.`,
34
+
35
+ // Tenant receives this 1–3 days before billing expires
36
+ billingReminder: (deviceName, daysLeft, fee) =>
37
+ `Reminder: Your WiFi device "${deviceName}" expires in ${daysLeft} day(s). Pay ${parseInt(fee).toLocaleString()} TZS to keep it active.`,
38
+
39
+ // Tenant receives this when requesting a password reset
40
+ passwordResetOtp: (otp) => `Token is: ${otp}\n.`,
41
+
42
+ // Tenant receives this when a payout completes
43
+ payoutCompleted: (amount) =>
44
+ `Payout of ${parseInt(amount).toLocaleString()} TZS sent to your M-Pesa.`,
45
+
46
+ // Tenant receives this when a payout fails
47
+ payoutFailed: (amount) =>
48
+ `Payout of ${parseInt(amount).toLocaleString()} TZS failed. Your balance has been restored.`,
49
+ };
src/views/portal.ejs ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= portal.businessName %> — WiFi</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
12
+ min-height: 100vh;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ padding: 16px;
17
+ }
18
+ .card {
19
+ background: #fff;
20
+ border-radius: 20px;
21
+ padding: 32px 24px;
22
+ width: 100%;
23
+ max-width: 380px;
24
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
25
+ }
26
+ .header {
27
+ text-align: center;
28
+ margin-bottom: 28px;
29
+ }
30
+ .header h1 {
31
+ font-size: 22px;
32
+ font-weight: 700;
33
+ color: #1a1a2e;
34
+ margin-bottom: 4px;
35
+ }
36
+ .header p {
37
+ color: #666;
38
+ font-size: 14px;
39
+ }
40
+ .wifi-icon {
41
+ font-size: 40px;
42
+ margin-bottom: 12px;
43
+ }
44
+ /* Screens */
45
+ .screen { display: none; }
46
+ .screen.active { display: block; }
47
+ /* Plans */
48
+ .plan-btn {
49
+ display: block;
50
+ width: 100%;
51
+ padding: 14px 16px;
52
+ margin-bottom: 10px;
53
+ background: #f8f9ff;
54
+ border: 2px solid #e8ecff;
55
+ border-radius: 12px;
56
+ cursor: pointer;
57
+ text-align: left;
58
+ transition: all 0.2s;
59
+ position: relative;
60
+ }
61
+ .plan-btn:hover, .plan-btn.selected {
62
+ border-color: #4361ee;
63
+ background: #eef0ff;
64
+ }
65
+ .plan-btn .plan-name { font-weight: 600; color: #1a1a2e; font-size: 15px; }
66
+ .plan-btn .plan-price { float: right; font-weight: 700; color: #4361ee; font-size: 16px; }
67
+ .plan-btn .plan-speed { font-size: 12px; color: #888; margin-top: 2px; }
68
+ /* Phone input */
69
+ .input-group { margin-top: 16px; }
70
+ .input-group label { font-size: 13px; color: #555; margin-bottom: 6px; display: block; }
71
+ .input-group input {
72
+ width: 100%;
73
+ padding: 12px 14px;
74
+ border: 2px solid #e0e0e0;
75
+ border-radius: 10px;
76
+ font-size: 16px;
77
+ outline: none;
78
+ transition: border-color 0.2s;
79
+ }
80
+ .input-group input:focus { border-color: #4361ee; }
81
+ /* Primary button */
82
+ .btn-primary {
83
+ display: block;
84
+ width: 100%;
85
+ padding: 15px;
86
+ margin-top: 16px;
87
+ background: #4361ee;
88
+ color: #fff;
89
+ border: none;
90
+ border-radius: 12px;
91
+ font-size: 16px;
92
+ font-weight: 600;
93
+ cursor: pointer;
94
+ transition: background 0.2s;
95
+ }
96
+ .btn-primary:hover { background: #3451d1; }
97
+ .btn-primary:disabled { background: #aaa; cursor: not-allowed; }
98
+ /* Secondary link */
99
+ .link-btn {
100
+ display: block;
101
+ text-align: center;
102
+ margin-top: 14px;
103
+ color: #4361ee;
104
+ font-size: 14px;
105
+ cursor: pointer;
106
+ text-decoration: underline;
107
+ }
108
+ /* Spinner */
109
+ .spinner {
110
+ text-align: center;
111
+ padding: 20px 0;
112
+ }
113
+ .spinner-ring {
114
+ width: 48px; height: 48px;
115
+ border: 4px solid #e8ecff;
116
+ border-top-color: #4361ee;
117
+ border-radius: 50%;
118
+ animation: spin 0.8s linear infinite;
119
+ margin: 0 auto 12px;
120
+ }
121
+ @keyframes spin { to { transform: rotate(360deg); } }
122
+ .spinner p { color: #555; font-size: 14px; }
123
+ /* Success */
124
+ .success-icon { font-size: 56px; text-align: center; margin-bottom: 12px; }
125
+ .code-box {
126
+ background: #f8f9ff;
127
+ border: 2px solid #4361ee;
128
+ border-radius: 12px;
129
+ padding: 16px;
130
+ text-align: center;
131
+ margin: 16px 0;
132
+ }
133
+ .code-box .code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
134
+ .code-box .code-value {
135
+ font-size: 28px;
136
+ font-weight: 700;
137
+ color: #1a1a2e;
138
+ letter-spacing: 3px;
139
+ font-family: monospace;
140
+ }
141
+ .meta-row {
142
+ display: flex;
143
+ justify-content: space-between;
144
+ font-size: 13px;
145
+ color: #555;
146
+ padding: 6px 0;
147
+ border-bottom: 1px solid #f0f0f0;
148
+ }
149
+ .meta-row:last-child { border-bottom: none; }
150
+ .meta-row strong { color: #1a1a2e; }
151
+ /* Error */
152
+ .error-msg {
153
+ background: #fff0f0;
154
+ border: 1px solid #ffcccc;
155
+ color: #cc0000;
156
+ border-radius: 8px;
157
+ padding: 10px 14px;
158
+ font-size: 13px;
159
+ margin-top: 12px;
160
+ display: none;
161
+ }
162
+ .error-msg.show { display: block; }
163
+ .divider { text-align: center; color: #bbb; font-size: 13px; margin: 16px 0; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <div class="card">
168
+ <div class="header">
169
+ <div class="wifi-icon">📶</div>
170
+ <h1><%= portal.businessName %></h1>
171
+ <p>Get online instantly</p>
172
+ </div>
173
+
174
+ <!-- SCREEN 1: Plan selection & phone -->
175
+ <div id="screen-select" class="screen active">
176
+ <% if (portal.plans.length === 0) { %>
177
+ <p style="text-align:center;color:#888;padding:20px 0;">No WiFi plans available yet.<br>Check back soon.</p>
178
+ <% } else { %>
179
+ <% portal.plans.forEach(function(plan) {
180
+ const unit = plan.down_unit === 2 ? 'Mbps' : 'Kbps';
181
+ const speed = plan.down_limit + unit + ' ↓ / ' + plan.up_limit + unit + ' ↑';
182
+ const hours = Math.floor(plan.duration_seconds / 3600);
183
+ const days = Math.floor(plan.duration_seconds / 86400);
184
+ const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hour' : ' Hours');
185
+ %>
186
+ <button class="plan-btn" data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
187
+ <span class="plan-name"><%= plan.name %></span>
188
+ <span class="plan-price"><%= parseInt(plan.price).toLocaleString() %> TZS</span>
189
+ <div class="plan-speed"><%= dur %> &bull; <%= speed %></div>
190
+ </button>
191
+ <% }); %>
192
+
193
+ <div class="input-group">
194
+ <label for="phone-input">Your M-Pesa / Mobile Money Phone Number</label>
195
+ <input type="tel" id="phone-input" placeholder="0712 345 678" inputmode="numeric">
196
+ </div>
197
+
198
+ <div class="error-msg" id="select-error"></div>
199
+ <button class="btn-primary" id="pay-btn" disabled>Select a plan to continue</button>
200
+
201
+ <div class="divider">or</div>
202
+ <span class="link-btn" id="show-code-entry">Have a code? Enter it here</span>
203
+ <% } %>
204
+ </div>
205
+
206
+ <!-- SCREEN 2: Waiting for payment -->
207
+ <div id="screen-waiting" class="screen">
208
+ <div class="spinner">
209
+ <div class="spinner-ring"></div>
210
+ <p>Waiting for M-Pesa payment...</p>
211
+ <p style="font-size:12px;color:#aaa;margin-top:8px;">Check your phone and enter your PIN</p>
212
+ </div>
213
+ <div class="error-msg" id="payment-error"></div>
214
+ <button class="btn-primary" id="cancel-btn" style="background:#eee;color:#555;">Cancel</button>
215
+ </div>
216
+
217
+ <!-- SCREEN 3: Code entry (reconnect) -->
218
+ <div id="screen-code" class="screen">
219
+ <div class="input-group">
220
+ <label for="code-input">Enter your WiFi code</label>
221
+ <input type="text" id="code-input" placeholder="XXXX-XXXX" maxlength="9" style="text-transform:uppercase;letter-spacing:2px;">
222
+ </div>
223
+ <div class="error-msg" id="code-error"></div>
224
+ <button class="btn-primary" id="code-submit-btn">Connect</button>
225
+ <span class="link-btn" id="show-plans">Buy a new plan instead</span>
226
+ </div>
227
+
228
+ <!-- SCREEN 4: Connected! -->
229
+ <div id="screen-success" class="screen">
230
+ <div class="success-icon">✅</div>
231
+ <h2 style="text-align:center;color:#1a1a2e;margin-bottom:8px;">Connected!</h2>
232
+ <div class="code-box">
233
+ <div class="code-label">Your WiFi Code (save this to reconnect)</div>
234
+ <div class="code-value" id="success-code">—</div>
235
+ </div>
236
+ <div id="success-meta"></div>
237
+ </div>
238
+ </div>
239
+
240
+ <script>
241
+ window.PORTAL = <%- JSON.stringify(portal).replace(/<\/script>/gi, '<\\/script>') %>;
242
+ </script>
243
+ <script src="/engine.js"></script>
244
+ </body>
245
+ </html>
src/views/screens/portal.ejs ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= portal.customize.displayName %> — WiFi</title>
7
+ <style>
8
+ :root {
9
+ --primary: <%= portal.customize.primaryColor %>;
10
+ --primary-dark: <%= portal.customize.primaryColor %>cc;
11
+ --bg: <%= portal.customize.bgColor %>;
12
+ }
13
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
+ background: var(--bg);
17
+ min-height: 100vh;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ padding: 16px;
22
+ }
23
+ .card {
24
+ background: #fff;
25
+ border-radius: 20px;
26
+ padding: 32px 24px;
27
+ width: 100%;
28
+ max-width: 380px;
29
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
30
+ }
31
+ .header {
32
+ text-align: center;
33
+ margin-bottom: 28px;
34
+ }
35
+ .header h1 {
36
+ font-size: 22px;
37
+ font-weight: 700;
38
+ color: #1a1a2e;
39
+ margin-bottom: 4px;
40
+ }
41
+ .header p {
42
+ color: #666;
43
+ font-size: 14px;
44
+ }
45
+ .wifi-icon {
46
+ font-size: 40px;
47
+ margin-bottom: 12px;
48
+ }
49
+ /* Screens */
50
+ .screen { display: none; }
51
+ .screen.active { display: block; }
52
+ /* Plans */
53
+ .plan-btn {
54
+ display: block;
55
+ width: 100%;
56
+ padding: 14px 16px;
57
+ margin-bottom: 10px;
58
+ background: #f8f9ff;
59
+ border: 2px solid #e8ecff;
60
+ border-radius: 12px;
61
+ cursor: pointer;
62
+ text-align: left;
63
+ transition: all 0.2s;
64
+ position: relative;
65
+ }
66
+ .plan-btn:hover, .plan-btn.selected {
67
+ border-color: var(--primary);
68
+ background: #eef0ff;
69
+ }
70
+ .plan-btn .plan-name { font-weight: 600; color: #1a1a2e; font-size: 15px; }
71
+ .plan-btn .plan-price { float: right; font-weight: 700; color: var(--primary); font-size: 16px; }
72
+ .plan-btn .plan-speed { font-size: 12px; color: #888; margin-top: 2px; }
73
+ /* Phone input */
74
+ .input-group { margin-top: 16px; }
75
+ .input-group label { font-size: 13px; color: #555; margin-bottom: 6px; display: block; }
76
+ .input-group input {
77
+ width: 100%;
78
+ padding: 12px 14px;
79
+ border: 2px solid #e0e0e0;
80
+ border-radius: 10px;
81
+ font-size: 16px;
82
+ outline: none;
83
+ transition: border-color 0.2s;
84
+ }
85
+ .input-group input:focus { border-color: var(--primary); }
86
+ /* Primary button */
87
+ .btn-primary {
88
+ display: block;
89
+ width: 100%;
90
+ padding: 15px;
91
+ margin-top: 16px;
92
+ background: var(--primary);
93
+ color: #fff;
94
+ border: none;
95
+ border-radius: 12px;
96
+ font-size: 16px;
97
+ font-weight: 600;
98
+ cursor: pointer;
99
+ transition: filter 0.2s;
100
+ }
101
+ .btn-primary:hover { filter: brightness(0.88); }
102
+ .btn-primary:disabled { background: #aaa; cursor: not-allowed; }
103
+ /* Secondary link */
104
+ .link-btn {
105
+ display: block;
106
+ text-align: center;
107
+ margin-top: 14px;
108
+ color: var(--primary);
109
+ font-size: 14px;
110
+ cursor: pointer;
111
+ text-decoration: underline;
112
+ }
113
+ /* Spinner */
114
+ .spinner {
115
+ text-align: center;
116
+ padding: 20px 0;
117
+ }
118
+ .spinner-ring {
119
+ width: 48px; height: 48px;
120
+ border: 4px solid #e8ecff;
121
+ border-top-color: var(--primary);
122
+ border-radius: 50%;
123
+ animation: spin 0.8s linear infinite;
124
+ margin: 0 auto 12px;
125
+ }
126
+ @keyframes spin { to { transform: rotate(360deg); } }
127
+ .spinner p { color: #555; font-size: 14px; }
128
+ /* Success */
129
+ .success-icon { font-size: 56px; text-align: center; margin-bottom: 12px; }
130
+ .code-box {
131
+ background: #f8f9ff;
132
+ border: 2px solid var(--primary);
133
+ border-radius: 12px;
134
+ padding: 16px;
135
+ text-align: center;
136
+ margin: 16px 0;
137
+ }
138
+ .code-box .code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
139
+ .code-box .code-value {
140
+ font-size: 28px;
141
+ font-weight: 700;
142
+ color: #1a1a2e;
143
+ letter-spacing: 3px;
144
+ font-family: monospace;
145
+ }
146
+ .meta-row {
147
+ display: flex;
148
+ justify-content: space-between;
149
+ font-size: 13px;
150
+ color: #555;
151
+ padding: 6px 0;
152
+ border-bottom: 1px solid #f0f0f0;
153
+ }
154
+ .meta-row:last-child { border-bottom: none; }
155
+ .meta-row strong { color: #1a1a2e; }
156
+ /* Error */
157
+ .error-msg {
158
+ background: #fff0f0;
159
+ border: 1px solid #ffcccc;
160
+ color: #cc0000;
161
+ border-radius: 8px;
162
+ padding: 10px 14px;
163
+ font-size: 13px;
164
+ margin-top: 12px;
165
+ display: none;
166
+ }
167
+ .error-msg.show { display: block; }
168
+ .divider { text-align: center; color: #bbb; font-size: 13px; margin: 16px 0; }
169
+ </style>
170
+ </head>
171
+ <body>
172
+ <div class="card">
173
+ <div class="header">
174
+ <% if (portal.customize.logoUrl) { %>
175
+ <img src="<%= portal.customize.logoUrl %>" alt="<%= portal.customize.displayName %>" style="max-height:64px;max-width:180px;object-fit:contain;margin-bottom:10px;">
176
+ <% } else { %>
177
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:12px">
178
+ <path d="M5 12.55a11 11 0 0 1 14.08 0"/>
179
+ <path d="M1.42 9a16 16 0 0 1 21.16 0"/>
180
+ <path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
181
+ <circle cx="12" cy="20" r="1" fill="var(--primary)" stroke="none"/>
182
+ </svg>
183
+ <% } %>
184
+ <h1><%= portal.customize.displayName %></h1>
185
+ <p><%= portal.customize.welcomeText %></p>
186
+ </div>
187
+
188
+ <!-- SCREEN 1: Plan selection & phone -->
189
+ <div id="screen-select" class="screen active">
190
+ <% if (portal.plans.length === 0) { %>
191
+ <p style="text-align:center;color:#888;padding:20px 0;">No WiFi plans available yet.<br>Check back soon.</p>
192
+ <% } else { %>
193
+ <% portal.plans.forEach(function(plan) {
194
+ const unit = plan.down_unit === 2 ? 'Mbps' : 'Kbps';
195
+ const speed = plan.down_limit + unit + ' ↓ / ' + plan.up_limit + unit + ' ↑';
196
+ const hours = Math.floor(plan.duration_seconds / 3600);
197
+ const days = Math.floor(plan.duration_seconds / 86400);
198
+ const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hour' : ' Hours');
199
+ %>
200
+ <button class="plan-btn" data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
201
+ <span class="plan-name"><%= plan.name %></span>
202
+ <span class="plan-price"><%= parseInt(plan.price).toLocaleString() %> TZS</span>
203
+ <div class="plan-speed"><%= dur %> &bull; <%= speed %></div>
204
+ </button>
205
+ <% }); %>
206
+
207
+ <div class="input-group">
208
+ <label for="phone-input">Your M-Pesa / Mobile Money Phone Number</label>
209
+ <input type="tel" id="phone-input" placeholder="0712 345 678" inputmode="numeric">
210
+ </div>
211
+
212
+ <div class="error-msg" id="select-error"></div>
213
+ <button class="btn-primary" id="pay-btn" disabled>Select a plan to continue</button>
214
+
215
+ <div class="divider">or</div>
216
+ <span class="link-btn" id="show-code-entry">Have a code? Enter it here</span>
217
+ <% } %>
218
+ </div>
219
+
220
+ <!-- SCREEN 2: Waiting for payment -->
221
+ <div id="screen-waiting" class="screen">
222
+ <div class="spinner">
223
+ <div class="spinner-ring"></div>
224
+ <p>Waiting for M-Pesa payment...</p>
225
+ <p style="font-size:12px;color:#aaa;margin-top:8px;">Check your phone and enter your PIN</p>
226
+ </div>
227
+ <div class="error-msg" id="payment-error"></div>
228
+ <button class="btn-primary" id="cancel-btn" style="background:#eee;color:#555;">Cancel</button>
229
+ </div>
230
+
231
+ <!-- SCREEN 3: Code entry (reconnect) -->
232
+ <div id="screen-code" class="screen">
233
+ <div class="input-group">
234
+ <label for="code-input">Enter your WiFi code</label>
235
+ <input type="text" id="code-input" placeholder="XXXX-XXXX" maxlength="9" style="text-transform:uppercase;letter-spacing:2px;">
236
+ </div>
237
+ <div class="error-msg" id="code-error"></div>
238
+ <button class="btn-primary" id="code-submit-btn">Connect</button>
239
+ <span class="link-btn" id="show-plans">Buy a new plan instead</span>
240
+ </div>
241
+
242
+ <!-- SCREEN 4: Connected! -->
243
+ <div id="screen-success" class="screen">
244
+ <svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px">
245
+ <circle cx="12" cy="12" r="10"/>
246
+ <polyline points="9 12 11 14 15 10"/>
247
+ </svg>
248
+ <h2 style="text-align:center;color:#1a1a2e;margin-bottom:8px;">Connected!</h2>
249
+ <div class="code-box">
250
+ <div class="code-label">Your WiFi Code (save this to reconnect)</div>
251
+ <div class="code-value" id="success-code">—</div>
252
+ </div>
253
+ <div id="success-meta"></div>
254
+ </div>
255
+ <% if (portal.customize.supportPhone) { %>
256
+ <p style="text-align:center;font-size:12px;color:#aaa;margin-top:20px;">
257
+ Need help? Call <a href="tel:<%= portal.customize.supportPhone %>" style="color:var(--primary);text-decoration:none;"><%= portal.customize.supportPhone %></a>
258
+ </p>
259
+ <% } %>
260
+ </div>
261
+
262
+ <script>
263
+ window.PORTAL = <%- JSON.stringify(portal).replace(/<\/script>/gi, '<\\/script>') %>;
264
+ </script>
265
+ <script src="/engine.js"></script>
266
+ </body>
267
+ </html>
src/views/screens/reconnected.ejs ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= businessName %> — Reconnected</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
12
+ min-height: 100vh;
13
+ display: flex; align-items: center; justify-content: center;
14
+ padding: 16px;
15
+ }
16
+ .card {
17
+ background: #fff;
18
+ border-radius: 20px;
19
+ padding: 40px 24px;
20
+ width: 100%; max-width: 380px;
21
+ text-align: center;
22
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
23
+ }
24
+ .icon { font-size: 56px; margin-bottom: 12px; }
25
+ h1 { font-size: 22px; font-weight: 700; color: #1a1a2e; margin-bottom: 6px; }
26
+ .subtitle { color: #666; font-size: 14px; margin-bottom: 24px; }
27
+ .code-box {
28
+ background: #f8f9ff;
29
+ border: 2px solid #4361ee;
30
+ border-radius: 12px;
31
+ padding: 16px;
32
+ margin-bottom: 20px;
33
+ }
34
+ .code-label { font-size: 12px; color: #888; margin-bottom: 4px; }
35
+ .code-value {
36
+ font-size: 28px;
37
+ font-weight: 700;
38
+ color: #1a1a2e;
39
+ letter-spacing: 3px;
40
+ font-family: monospace;
41
+ }
42
+ .meta-row {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ font-size: 13px;
46
+ color: #555;
47
+ padding: 7px 0;
48
+ border-bottom: 1px solid #f0f0f0;
49
+ }
50
+ .meta-row:last-child { border-bottom: none; }
51
+ .meta-row strong { color: #1a1a2e; }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div class="card">
56
+ <svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 12px">
57
+ <circle cx="12" cy="12" r="10"/>
58
+ <polyline points="9 12 11 14 15 10"/>
59
+ </svg>
60
+ <h1>Welcome back!</h1>
61
+ <p class="subtitle"><%= businessName %> — You're connected</p>
62
+
63
+ <div class="code-box">
64
+ <div class="code-label">Your WiFi Code</div>
65
+ <div class="code-value"><%= code %></div>
66
+ </div>
67
+
68
+ <div class="meta-row">
69
+ <span>Speed</span>
70
+ <strong><%= speedDown %> ↓ / <%= speedUp %> ↑</strong>
71
+ </div>
72
+ <div class="meta-row">
73
+ <span>Time remaining</span>
74
+ <strong><%= expiresIn >= 3600 ? Math.ceil(expiresIn / 3600) + 'h' : Math.ceil(expiresIn / 60) + 'min' %></strong>
75
+ </div>
76
+ <div class="meta-row">
77
+ <span>Reconnect?</span>
78
+ <strong>Enter code above on login page</strong>
79
+ </div>
80
+ </div>
81
+ </body>
82
+ </html>
src/views/screens/suspended.ejs ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Subscription Required</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
12
+ min-height: 100vh;
13
+ display: flex; align-items: center; justify-content: center;
14
+ padding: 16px;
15
+ }
16
+ .card {
17
+ background: #fff;
18
+ border-radius: 20px;
19
+ padding: 40px 24px;
20
+ width: 100%; max-width: 360px;
21
+ text-align: center;
22
+ }
23
+ .icon { font-size: 56px; margin-bottom: 16px; }
24
+ h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 10px; }
25
+ .business { font-size: 15px; font-weight: 600; color: #0492eb; margin-bottom: 16px; }
26
+ p { color: #666; font-size: 14px; line-height: 1.6; }
27
+ .divider { border: none; border-top: 1px solid #eee; margin: 20px 0; }
28
+ .notice {
29
+ background: #fff8e1;
30
+ border: 1px solid #ffe082;
31
+ border-radius: 10px;
32
+ padding: 12px 16px;
33
+ font-size: 13px;
34
+ color: #7a6000;
35
+ line-height: 1.5;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div class="card">
41
+ <svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 16px">
42
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
43
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
44
+ </svg>
45
+ <div class="business"><%= businessName %></div>
46
+ <h1>Subscription Required</h1>
47
+ <p>WiFi service at this location is temporarily unavailable.</p>
48
+ <hr class="divider">
49
+ <div class="notice">
50
+ If you are the owner of this hotspot, please renew your subscription to restore WiFi service for your customers.
51
+ </div>
52
+ </div>
53
+ </body>
54
+ </html>
src/views/suspended.ejs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Subscription Required</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
12
+ min-height: 100vh;
13
+ display: flex; align-items: center; justify-content: center;
14
+ padding: 16px;
15
+ }
16
+ .card {
17
+ background: #fff;
18
+ border-radius: 20px;
19
+ padding: 40px 24px;
20
+ width: 100%; max-width: 360px;
21
+ text-align: center;
22
+ }
23
+ .icon { font-size: 56px; margin-bottom: 16px; }
24
+ h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 10px; }
25
+ .business { font-size: 15px; font-weight: 600; color: #0492eb; margin-bottom: 16px; }
26
+ p { color: #666; font-size: 14px; line-height: 1.6; }
27
+ .divider { border: none; border-top: 1px solid #eee; margin: 20px 0; }
28
+ .notice {
29
+ background: #fff8e1;
30
+ border: 1px solid #ffe082;
31
+ border-radius: 10px;
32
+ padding: 12px 16px;
33
+ font-size: 13px;
34
+ color: #7a6000;
35
+ line-height: 1.5;
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div class="card">
41
+ <div class="icon">🔒</div>
42
+ <div class="business"><%= businessName %></div>
43
+ <h1>Subscription Required</h1>
44
+ <p>WiFi service at this location is temporarily unavailable.</p>
45
+ <hr class="divider">
46
+ <div class="notice">
47
+ If you are the owner of this hotspot, please renew your subscription to restore WiFi service for your customers.
48
+ </div>
49
+ </div>
50
+ </body>
51
+ </html>