Mbonea commited on
Commit
ff4d747
·
1 Parent(s): 0b1d3dc

Add tenant direct payment token sales

Browse files
API_DOCUMENTATION.md CHANGED
@@ -13,13 +13,14 @@
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
 
@@ -258,6 +259,62 @@ Update one or more tenant notification settings.
258
 
259
  ---
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  ### PATCH `/api/auth/profile`
262
  Update contact name, business name, or email. Send only the fields you want to change.
263
 
@@ -860,7 +917,103 @@ Session history (active and ended).
860
 
861
  ---
862
 
863
- ## 6. Tenant — Payouts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
 
865
  Tenants withdraw their earned balance via M-Pesa.
866
 
@@ -928,7 +1081,7 @@ Request a withdrawal to M-Pesa.
928
 
929
  ---
930
 
931
- ## 7. Tenant — Alerts
932
 
933
  Alerts are shown in-app from two sources:
934
  - Omada/system alerts such as device offline, disconnected, or client-block events
@@ -980,7 +1133,7 @@ Mark an alert as resolved.
980
 
981
  ---
982
 
983
- ## 8. Super Admin
984
 
985
  All admin endpoints require a static admin token passed as a Bearer token.
986
  This is a **separate secret** from tenant JWTs — set via `ADMIN_TOKEN` environment variable.
@@ -1178,7 +1331,7 @@ Sets `billing_status = 'active'` and extends `billing_expires_at` by 30 days fro
1178
 
1179
  ---
1180
 
1181
- ## 9. Captive Portal (Public)
1182
 
1183
  These endpoints are hit by the AP firmware redirect and the guest's browser. No authentication required.
1184
 
@@ -1281,7 +1434,7 @@ Authorize a guest on the WiFi using their access code.
1281
 
1282
  ---
1283
 
1284
- ## 10. Error Format
1285
 
1286
  All errors follow this shape:
1287
 
@@ -1303,7 +1456,7 @@ All errors follow this shape:
1303
 
1304
  ---
1305
 
1306
- ## 11. Enums & Constants
1307
 
1308
  ### Device `status`
1309
  | Value | Meaning |
@@ -1354,7 +1507,7 @@ All errors follow this shape:
1354
 
1355
  ---
1356
 
1357
- ## 12. UI Building Guide
1358
 
1359
  ### Tenant Dashboard — Recommended Pages
1360
 
@@ -1368,6 +1521,8 @@ All errors follow this shape:
1368
  | Manage Plans | `POST /api/plans`, `PUT /api/plans/:id`, `DELETE /api/plans/:id` |
1369
  | Session History | `GET /api/sessions` |
1370
  | Notification Settings | `GET /api/auth/notification-preferences`, `PATCH /api/auth/notification-preferences` |
 
 
1371
  | Payouts | `GET /api/payouts`, `POST /api/payouts` |
1372
  | Alerts | `GET /api/alerts/unresolved`, `PATCH /api/alerts/:id/resolve` |
1373
  | Profile | `GET /api/auth/me` |
 
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 — Manual Sales](#6-tenant--manual-sales)
17
+ 7. [Tenant — Payouts](#7-tenant--payouts)
18
+ 8. [Tenant — Alerts](#8-tenant--alerts)
19
+ 9. [Super Admin](#9-super-admin)
20
+ 10. [Captive Portal (Public)](#10-captive-portal-public)
21
+ 11. [Error Format](#11-error-format)
22
+ 12. [Enums & Constants](#12-enums--constants)
23
+ 13. [UI Building Guide](#13-ui-building-guide)
24
 
25
  ---
26
 
 
259
 
260
  ---
261
 
262
+ ### GET `/api/auth/payment-settings`
263
+ Get the tenant's direct-payment receiving settings used for manual token sales.
264
+
265
+ **Auth:** Required
266
+
267
+ **Response `200`:**
268
+ ```json
269
+ {
270
+ "payment_display_name": "Gidion Mathayo Chiyao",
271
+ "payment_phone": "070024519",
272
+ "payment_provider": "selcom",
273
+ "payment_instructions": "Customer pays directly, then attendant confirms.",
274
+ "payment_qr_image_url": null,
275
+ "manual_sales_enabled": true
276
+ }
277
+ ```
278
+
279
+ ---
280
+
281
+ ### PATCH `/api/auth/payment-settings`
282
+ Update the tenant's direct-payment receiving settings.
283
+
284
+ If `manual_sales_enabled` is `true`, the account must have:
285
+ - `payment_display_name`
286
+ - `payment_phone`
287
+ - `payment_provider`
288
+
289
+ **Auth:** Required
290
+
291
+ **Body:**
292
+ ```json
293
+ {
294
+ "payment_display_name": "Gidion Mathayo Chiyao",
295
+ "payment_phone": "070024519",
296
+ "payment_provider": "selcom",
297
+ "payment_instructions": "Pay this number, then confirm from the dashboard.",
298
+ "payment_qr_image_url": null,
299
+ "manual_sales_enabled": true
300
+ }
301
+ ```
302
+
303
+ **Response `200`:**
304
+ ```json
305
+ {
306
+ "payment_display_name": "Gidion Mathayo Chiyao",
307
+ "payment_phone": "070024519",
308
+ "payment_provider": "selcom",
309
+ "payment_instructions": "Pay this number, then confirm from the dashboard.",
310
+ "payment_qr_image_url": null,
311
+ "manual_sales_enabled": true,
312
+ "message": "Payment settings updated"
313
+ }
314
+ ```
315
+
316
+ ---
317
+
318
  ### PATCH `/api/auth/profile`
319
  Update contact name, business name, or email. Send only the fields you want to change.
320
 
 
917
 
918
  ---
919
 
920
+ ## 6. Tenant — Manual Sales
921
+
922
+ Manual sales are tenant-confirmed direct payments. The customer pays the tenant outside WifiBiz, the tenant confirms from the dashboard, and WifiBiz generates a token.
923
+
924
+ Important accounting rule:
925
+ - manual sales generate tokens
926
+ - manual sales are recorded in `payments`
927
+ - manual sales do **not** increase withdrawable platform balance
928
+ - manual sales do **not** enter payout calculations
929
+
930
+ ---
931
+
932
+ ### GET `/api/manual-sales`
933
+ Recent manual token sales for the authenticated tenant.
934
+
935
+ **Auth:** Required
936
+
937
+ **Query params:**
938
+ - `limit` optional, default `50`, max `100`
939
+
940
+ **Response `200`:**
941
+ ```json
942
+ [
943
+ {
944
+ "id": 41,
945
+ "reference": "MAN-1A2B3C4D5E6F7G8H9J0K",
946
+ "customer_phone": null,
947
+ "phone_provider": "selcom",
948
+ "gross_amount": 1000.00,
949
+ "payment_channel": "tenant_direct_manual",
950
+ "verification_source": "tenant_confirmed",
951
+ "counts_toward_balance": 0,
952
+ "notes": "Confirmed from counter phone",
953
+ "confirmed_at": "2026-05-11T08:15:00.000Z",
954
+ "completed_at": "2026-05-11T08:15:00.000Z",
955
+ "access_token_code": "ABCD-1234",
956
+ "device_id": 3,
957
+ "device_name": "Gaza",
958
+ "plan_id": 7,
959
+ "plan_name": "Siku Unlimited",
960
+ "duration_seconds": 86400
961
+ }
962
+ ]
963
+ ```
964
+
965
+ ---
966
+
967
+ ### POST `/api/manual-sales`
968
+ Create a tenant-confirmed manual sale and generate a WiFi token from the selected plan.
969
+
970
+ Plan selection is mandatory. The backend copies the selected plan's real price, duration, and speed limits into the payment/token.
971
+
972
+ **Auth:** Required
973
+
974
+ **Body:**
975
+ ```json
976
+ {
977
+ "device_id": 3,
978
+ "plan_id": 7,
979
+ "customer_phone": "0712345678",
980
+ "notes": "Paid via tenant Selcom QR"
981
+ }
982
+ ```
983
+
984
+ **Validation:**
985
+ - `device_id` required
986
+ - `plan_id` required
987
+ - selected plan must belong to the selected device
988
+ - selected plan must be active
989
+ - device billing must be `trial` or `active`
990
+ - tenant account must have manual sales enabled in payment settings
991
+
992
+ **Response `201`:**
993
+ ```json
994
+ {
995
+ "payment_id": 41,
996
+ "reference": "MAN-1A2B3C4D5E6F7G8H9J0K",
997
+ "payment_channel": "tenant_direct_manual",
998
+ "verification_source": "tenant_confirmed",
999
+ "counts_toward_balance": false,
1000
+ "token_id": 88,
1001
+ "token_code": "ABCD-1234",
1002
+ "customer_phone": "0712345678",
1003
+ "amount": 1000,
1004
+ "device_id": 3,
1005
+ "device_name": "Gaza",
1006
+ "plan_id": 7,
1007
+ "plan_name": "Siku Unlimited",
1008
+ "duration_seconds": 86400,
1009
+ "notes": "Paid via tenant Selcom QR",
1010
+ "message": "Token generated successfully"
1011
+ }
1012
+ ```
1013
+
1014
+ ---
1015
+
1016
+ ## 7. Tenant — Payouts
1017
 
1018
  Tenants withdraw their earned balance via M-Pesa.
1019
 
 
1081
 
1082
  ---
1083
 
1084
+ ## 8. Tenant — Alerts
1085
 
1086
  Alerts are shown in-app from two sources:
1087
  - Omada/system alerts such as device offline, disconnected, or client-block events
 
1133
 
1134
  ---
1135
 
1136
+ ## 9. Super Admin
1137
 
1138
  All admin endpoints require a static admin token passed as a Bearer token.
1139
  This is a **separate secret** from tenant JWTs — set via `ADMIN_TOKEN` environment variable.
 
1331
 
1332
  ---
1333
 
1334
+ ## 10. Captive Portal (Public)
1335
 
1336
  These endpoints are hit by the AP firmware redirect and the guest's browser. No authentication required.
1337
 
 
1434
 
1435
  ---
1436
 
1437
+ ## 11. Error Format
1438
 
1439
  All errors follow this shape:
1440
 
 
1456
 
1457
  ---
1458
 
1459
+ ## 12. Enums & Constants
1460
 
1461
  ### Device `status`
1462
  | Value | Meaning |
 
1507
 
1508
  ---
1509
 
1510
+ ## 13. UI Building Guide
1511
 
1512
  ### Tenant Dashboard — Recommended Pages
1513
 
 
1521
  | Manage Plans | `POST /api/plans`, `PUT /api/plans/:id`, `DELETE /api/plans/:id` |
1522
  | Session History | `GET /api/sessions` |
1523
  | Notification Settings | `GET /api/auth/notification-preferences`, `PATCH /api/auth/notification-preferences` |
1524
+ | Payment Settings | `GET /api/auth/payment-settings`, `PATCH /api/auth/payment-settings` |
1525
+ | Manual Token Sales | `GET /api/manual-sales`, `POST /api/manual-sales`, `GET /api/plans?deviceId=` |
1526
  | Payouts | `GET /api/payouts`, `POST /api/payouts` |
1527
  | Alerts | `GET /api/alerts/unresolved`, `PATCH /api/alerts/:id/resolve` |
1528
  | Profile | `GET /api/auth/me` |
schema.sql CHANGED
@@ -17,6 +17,12 @@ CREATE TABLE `clients` (
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,
@@ -180,8 +186,14 @@ CREATE TABLE `payments` (
180
  `client_id` INT UNSIGNED NOT NULL,
181
  `device_id` INT UNSIGNED NOT NULL,
182
  `plan_id` INT UNSIGNED NOT NULL,
183
- `phone` VARCHAR(20) NOT NULL,
184
- `phone_provider` ENUM('mpesa','airtel','tigo','halopesa') NOT NULL DEFAULT 'mpesa',
 
 
 
 
 
 
185
  `gross_amount` DECIMAL(10,2) NOT NULL,
186
  `snippe_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
187
  `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
 
17
  `contact_name` VARCHAR(100) NOT NULL,
18
  `email` VARCHAR(255) NOT NULL,
19
  `phone` VARCHAR(20) NOT NULL,
20
+ `payment_display_name` VARCHAR(100) DEFAULT NULL,
21
+ `payment_phone` VARCHAR(20) DEFAULT NULL,
22
+ `payment_provider` VARCHAR(32) DEFAULT NULL,
23
+ `payment_instructions` VARCHAR(255) DEFAULT NULL,
24
+ `payment_qr_image_url` VARCHAR(512) DEFAULT NULL,
25
+ `manual_sales_enabled` TINYINT(1) NOT NULL DEFAULT 0,
26
  `password_hash` VARCHAR(255) NOT NULL,
27
  `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
28
  `balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
 
186
  `client_id` INT UNSIGNED NOT NULL,
187
  `device_id` INT UNSIGNED NOT NULL,
188
  `plan_id` INT UNSIGNED NOT NULL,
189
+ `phone` VARCHAR(20) DEFAULT NULL,
190
+ `phone_provider` ENUM('mpesa','selcom','airtel','tigo','halopesa','bank','other') NOT NULL DEFAULT 'mpesa',
191
+ `payment_channel` ENUM('snippe','tenant_direct_manual') NOT NULL DEFAULT 'snippe',
192
+ `verification_source` ENUM('webhook','tenant_confirmed') DEFAULT NULL,
193
+ `counts_toward_balance` TINYINT(1) NOT NULL DEFAULT 1,
194
+ `confirmed_by_client_id` INT UNSIGNED DEFAULT NULL,
195
+ `confirmed_at` TIMESTAMP NULL DEFAULT NULL,
196
+ `notes` VARCHAR(255) DEFAULT NULL,
197
  `gross_amount` DECIMAL(10,2) NOT NULL,
198
  `snippe_fee` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
199
  `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
src/app.js CHANGED
@@ -47,6 +47,7 @@ app.use('/api/dashboard', require('./routes/dashboard.routes'));
47
  app.use('/api/payouts', require('./routes/payouts.routes'));
48
  app.use('/api/sessions', require('./routes/sessions.routes'));
49
  app.use('/api/alerts', require('./routes/alerts.routes'));
 
50
 
51
  // Admin API
52
  app.use('/api/admin', require('./routes/admin.routes'));
 
47
  app.use('/api/payouts', require('./routes/payouts.routes'));
48
  app.use('/api/sessions', require('./routes/sessions.routes'));
49
  app.use('/api/alerts', require('./routes/alerts.routes'));
50
+ app.use('/api/manual-sales', require('./routes/manualSales.routes'));
51
 
52
  // Admin API
53
  app.use('/api/admin', require('./routes/admin.routes'));
src/routes/auth.routes.js CHANGED
@@ -12,6 +12,8 @@ const {
12
  const { requireAuth, signToken } = require('../middleware/auth');
13
  const { loginLimiter, registerLimiter, forgotPasswordLimiter, phoneOtpLimiter } = require('../middleware/rateLimiter');
14
 
 
 
15
  // POST /api/auth/register
16
  router.post('/register', registerLimiter, async (req, res) => {
17
  const { business_name, contact_name, email, phone, password } = req.body;
@@ -301,7 +303,11 @@ router.post('/phone/confirm', requireAuth, async (req, res) => {
301
  router.get('/me', requireAuth, async (req, res) => {
302
  try {
303
  const client = await db.query(
304
- 'SELECT id, business_name, contact_name, email, phone, balance, total_earned, total_withdrawn, created_at FROM clients WHERE id = ?',
 
 
 
 
305
  [req.client.id]
306
  ).then(r => r[0]);
307
 
@@ -312,6 +318,154 @@ router.get('/me', requireAuth, async (req, res) => {
312
  }
313
  });
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  // GET /api/auth/notification-preferences
316
  router.get('/notification-preferences', requireAuth, async (req, res) => {
317
  try {
 
12
  const { requireAuth, signToken } = require('../middleware/auth');
13
  const { loginLimiter, registerLimiter, forgotPasswordLimiter, phoneOtpLimiter } = require('../middleware/rateLimiter');
14
 
15
+ const ALLOWED_PAYMENT_PROVIDERS = ['mpesa', 'selcom', 'airtel', 'tigo', 'halopesa', 'bank', 'other'];
16
+
17
  // POST /api/auth/register
18
  router.post('/register', registerLimiter, async (req, res) => {
19
  const { business_name, contact_name, email, phone, password } = req.body;
 
303
  router.get('/me', requireAuth, async (req, res) => {
304
  try {
305
  const client = await db.query(
306
+ `SELECT id, business_name, contact_name, email, phone,
307
+ payment_display_name, payment_phone, payment_provider,
308
+ payment_instructions, payment_qr_image_url, manual_sales_enabled,
309
+ balance, total_earned, total_withdrawn, created_at
310
+ FROM clients WHERE id = ?`,
311
  [req.client.id]
312
  ).then(r => r[0]);
313
 
 
318
  }
319
  });
320
 
321
+ // GET /api/auth/payment-settings
322
+ router.get('/payment-settings', requireAuth, async (req, res) => {
323
+ try {
324
+ const settings = await db.queryOne(
325
+ `SELECT payment_display_name, payment_phone, payment_provider,
326
+ payment_instructions, payment_qr_image_url, manual_sales_enabled
327
+ FROM clients WHERE id = ?`,
328
+ [req.client.id]
329
+ );
330
+
331
+ if (!settings) return res.status(404).json({ error: 'Account not found' });
332
+
333
+ res.json({
334
+ payment_display_name: settings.payment_display_name,
335
+ payment_phone: settings.payment_phone,
336
+ payment_provider: settings.payment_provider,
337
+ payment_instructions: settings.payment_instructions,
338
+ payment_qr_image_url: settings.payment_qr_image_url,
339
+ manual_sales_enabled: Boolean(settings.manual_sales_enabled),
340
+ });
341
+ } catch (err) {
342
+ console.error('[auth/payment-settings GET]', err.message);
343
+ res.status(500).json({ error: 'Failed to fetch payment settings' });
344
+ }
345
+ });
346
+
347
+ // PATCH /api/auth/payment-settings
348
+ router.patch('/payment-settings', requireAuth, async (req, res) => {
349
+ const {
350
+ payment_display_name,
351
+ payment_phone,
352
+ payment_provider,
353
+ payment_instructions,
354
+ payment_qr_image_url,
355
+ manual_sales_enabled,
356
+ } = req.body;
357
+
358
+ const updates = [];
359
+ const values = [];
360
+
361
+ if (payment_display_name !== undefined) {
362
+ if (payment_display_name !== null && typeof payment_display_name !== 'string') {
363
+ return res.status(400).json({ error: 'payment_display_name must be a string or null' });
364
+ }
365
+ updates.push('payment_display_name = ?');
366
+ values.push(payment_display_name?.trim() || null);
367
+ }
368
+
369
+ if (payment_phone !== undefined) {
370
+ if (payment_phone !== null && typeof payment_phone !== 'string') {
371
+ return res.status(400).json({ error: 'payment_phone must be a string or null' });
372
+ }
373
+ updates.push('payment_phone = ?');
374
+ values.push(payment_phone?.trim() || null);
375
+ }
376
+
377
+ if (payment_provider !== undefined) {
378
+ if (payment_provider !== null && typeof payment_provider !== 'string') {
379
+ return res.status(400).json({ error: 'payment_provider must be a string or null' });
380
+ }
381
+ if (payment_provider && !ALLOWED_PAYMENT_PROVIDERS.includes(payment_provider)) {
382
+ return res.status(400).json({ error: `payment_provider must be one of: ${ALLOWED_PAYMENT_PROVIDERS.join(', ')}` });
383
+ }
384
+ updates.push('payment_provider = ?');
385
+ values.push(payment_provider || null);
386
+ }
387
+
388
+ if (payment_instructions !== undefined) {
389
+ if (payment_instructions !== null && typeof payment_instructions !== 'string') {
390
+ return res.status(400).json({ error: 'payment_instructions must be a string or null' });
391
+ }
392
+ updates.push('payment_instructions = ?');
393
+ values.push(payment_instructions?.trim() || null);
394
+ }
395
+
396
+ if (payment_qr_image_url !== undefined) {
397
+ if (payment_qr_image_url !== null && typeof payment_qr_image_url !== 'string') {
398
+ return res.status(400).json({ error: 'payment_qr_image_url must be a string or null' });
399
+ }
400
+ updates.push('payment_qr_image_url = ?');
401
+ values.push(payment_qr_image_url?.trim() || null);
402
+ }
403
+
404
+ if (manual_sales_enabled !== undefined) {
405
+ if (typeof manual_sales_enabled !== 'boolean') {
406
+ return res.status(400).json({ error: 'manual_sales_enabled must be boolean' });
407
+ }
408
+ updates.push('manual_sales_enabled = ?');
409
+ values.push(manual_sales_enabled ? 1 : 0);
410
+ }
411
+
412
+ if (!updates.length) {
413
+ return res.status(400).json({ error: 'No payment setting updates provided' });
414
+ }
415
+
416
+ const enablingManualSales = manual_sales_enabled === true;
417
+
418
+ try {
419
+ const current = await db.queryOne(
420
+ `SELECT payment_display_name, payment_phone, payment_provider, manual_sales_enabled
421
+ FROM clients WHERE id = ?`,
422
+ [req.client.id]
423
+ );
424
+
425
+ if (!current) return res.status(404).json({ error: 'Account not found' });
426
+
427
+ const merged = {
428
+ payment_display_name: payment_display_name !== undefined ? (payment_display_name?.trim() || null) : current.payment_display_name,
429
+ payment_phone: payment_phone !== undefined ? (payment_phone?.trim() || null) : current.payment_phone,
430
+ payment_provider: payment_provider !== undefined ? (payment_provider || null) : current.payment_provider,
431
+ manual_sales_enabled: manual_sales_enabled !== undefined ? manual_sales_enabled : Boolean(current.manual_sales_enabled),
432
+ };
433
+
434
+ if (enablingManualSales || merged.manual_sales_enabled) {
435
+ if (!merged.payment_display_name || !merged.payment_phone || !merged.payment_provider) {
436
+ return res.status(400).json({
437
+ error: 'payment_display_name, payment_phone, and payment_provider are required when manual sales are enabled',
438
+ });
439
+ }
440
+ }
441
+
442
+ await db.query(
443
+ `UPDATE clients SET ${updates.join(', ')} WHERE id = ?`,
444
+ [...values, req.client.id]
445
+ );
446
+
447
+ const updated = await db.queryOne(
448
+ `SELECT payment_display_name, payment_phone, payment_provider,
449
+ payment_instructions, payment_qr_image_url, manual_sales_enabled
450
+ FROM clients WHERE id = ?`,
451
+ [req.client.id]
452
+ );
453
+
454
+ res.json({
455
+ payment_display_name: updated.payment_display_name,
456
+ payment_phone: updated.payment_phone,
457
+ payment_provider: updated.payment_provider,
458
+ payment_instructions: updated.payment_instructions,
459
+ payment_qr_image_url: updated.payment_qr_image_url,
460
+ manual_sales_enabled: Boolean(updated.manual_sales_enabled),
461
+ message: 'Payment settings updated',
462
+ });
463
+ } catch (err) {
464
+ console.error('[auth/payment-settings PATCH]', err.message);
465
+ res.status(500).json({ error: 'Failed to update payment settings' });
466
+ }
467
+ });
468
+
469
  // GET /api/auth/notification-preferences
470
  router.get('/notification-preferences', requireAuth, async (req, res) => {
471
  try {
src/routes/manualSales.routes.js ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const router = require('express').Router();
2
+ const db = require('../config/db');
3
+ const { requireAuth } = require('../middleware/auth');
4
+ const { v4: uuidv4 } = require('uuid');
5
+ const { createAccessTokenForPayment } = require('../utils/tokenSales');
6
+
7
+ const MANUAL_PAYMENT_PROVIDERS = new Set(['mpesa', 'selcom', 'airtel', 'tigo', 'halopesa', 'bank', 'other']);
8
+
9
+ router.use(requireAuth);
10
+
11
+ // GET /api/manual-sales
12
+ router.get('/', async (req, res) => {
13
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
14
+
15
+ try {
16
+ const sales = await db.query(
17
+ `SELECT p.id, p.reference, p.phone AS customer_phone, p.phone_provider,
18
+ p.gross_amount, p.payment_channel, p.verification_source,
19
+ p.counts_toward_balance, p.notes, p.confirmed_at, p.completed_at,
20
+ p.access_token_code,
21
+ d.id AS device_id, d.name AS device_name,
22
+ wp.id AS plan_id, wp.name AS plan_name, wp.duration_seconds
23
+ FROM payments p
24
+ JOIN devices d ON d.id = p.device_id
25
+ JOIN wifi_plans wp ON wp.id = p.plan_id
26
+ WHERE p.client_id = ?
27
+ AND p.payment_channel = 'tenant_direct_manual'
28
+ ORDER BY COALESCE(p.confirmed_at, p.completed_at, p.created_at) DESC
29
+ LIMIT ?`,
30
+ [req.client.id, limit]
31
+ );
32
+
33
+ res.json(sales);
34
+ } catch (err) {
35
+ console.error('[manual-sales/list]', err.message);
36
+ res.status(500).json({ error: 'Failed to fetch manual sales' });
37
+ }
38
+ });
39
+
40
+ // POST /api/manual-sales
41
+ router.post('/', async (req, res) => {
42
+ const { device_id, plan_id, customer_phone = null, notes = null } = req.body;
43
+
44
+ if (!device_id || !plan_id) {
45
+ return res.status(400).json({ error: 'device_id and plan_id are required' });
46
+ }
47
+ if (customer_phone !== null && typeof customer_phone !== 'string') {
48
+ return res.status(400).json({ error: 'customer_phone must be a string or null' });
49
+ }
50
+ if (notes !== null && typeof notes !== 'string') {
51
+ return res.status(400).json({ error: 'notes must be a string or null' });
52
+ }
53
+ if (notes && notes.length > 255) {
54
+ return res.status(400).json({ error: 'notes cannot exceed 255 characters' });
55
+ }
56
+
57
+ const connection = await db.pool.getConnection();
58
+
59
+ try {
60
+ await connection.beginTransaction();
61
+
62
+ const [clientRows] = await connection.execute(
63
+ `SELECT id, commission_rate, payment_display_name, payment_phone, payment_provider,
64
+ payment_qr_image_url, manual_sales_enabled
65
+ FROM clients WHERE id = ? LIMIT 1`,
66
+ [req.client.id]
67
+ );
68
+ const client = clientRows[0];
69
+
70
+ if (!client) {
71
+ await connection.rollback();
72
+ return res.status(404).json({ error: 'Account not found' });
73
+ }
74
+ if (!client.manual_sales_enabled) {
75
+ await connection.rollback();
76
+ return res.status(403).json({ error: 'Manual token sales are disabled for this account' });
77
+ }
78
+ if (!client.payment_display_name || !client.payment_phone || !client.payment_provider) {
79
+ await connection.rollback();
80
+ return res.status(400).json({ error: 'Complete your payment settings before generating manual tokens' });
81
+ }
82
+
83
+ const [deviceRows] = await connection.execute(
84
+ `SELECT id, name, billing_status
85
+ FROM devices
86
+ WHERE id = ? AND client_id = ? LIMIT 1`,
87
+ [device_id, req.client.id]
88
+ );
89
+ const device = deviceRows[0];
90
+
91
+ if (!device) {
92
+ await connection.rollback();
93
+ return res.status(404).json({ error: 'Device not found' });
94
+ }
95
+ if (!['trial', 'active'].includes(device.billing_status)) {
96
+ await connection.rollback();
97
+ return res.status(403).json({ error: 'Device billing is not active for manual token generation' });
98
+ }
99
+
100
+ const [planRows] = await connection.execute(
101
+ `SELECT id, name, duration_seconds, price,
102
+ down_limit, down_unit, up_limit, up_unit, is_active
103
+ FROM wifi_plans
104
+ WHERE id = ? AND device_id = ? AND client_id = ? LIMIT 1`,
105
+ [plan_id, device.id, req.client.id]
106
+ );
107
+ const plan = planRows[0];
108
+
109
+ if (!plan) {
110
+ await connection.rollback();
111
+ return res.status(404).json({ error: 'Plan not found' });
112
+ }
113
+ if (!plan.is_active) {
114
+ await connection.rollback();
115
+ return res.status(400).json({ error: 'Selected plan is inactive' });
116
+ }
117
+
118
+ const reference = `MAN-${uuidv4().replace(/-/g, '').slice(0, 20).toUpperCase()}`;
119
+ const provider = MANUAL_PAYMENT_PROVIDERS.has(client.payment_provider)
120
+ ? client.payment_provider
121
+ : 'other';
122
+
123
+ const [paymentResult] = await connection.execute(
124
+ `INSERT INTO payments
125
+ (reference, client_id, device_id, plan_id, phone, phone_provider,
126
+ payment_channel, verification_source, counts_toward_balance,
127
+ confirmed_by_client_id, confirmed_at, notes,
128
+ gross_amount, snippe_fee, commission_rate, commission_amount, client_credit,
129
+ status, completed_at)
130
+ VALUES (?, ?, ?, ?, ?, ?, 'tenant_direct_manual', 'tenant_confirmed', 0,
131
+ ?, NOW(), ?, ?, 0.00, 0.0000, 0.00, 0.00,
132
+ 'completed', NOW())`,
133
+ [
134
+ reference,
135
+ req.client.id,
136
+ device.id,
137
+ plan.id,
138
+ customer_phone?.trim() || null,
139
+ provider,
140
+ req.client.id,
141
+ notes?.trim() || null,
142
+ plan.price,
143
+ ]
144
+ );
145
+
146
+ const { tokenId, code } = await createAccessTokenForPayment({
147
+ connection,
148
+ paymentId: paymentResult.insertId,
149
+ clientId: req.client.id,
150
+ deviceId: device.id,
151
+ planId: plan.id,
152
+ durationSeconds: plan.duration_seconds,
153
+ downLimit: plan.down_limit,
154
+ downUnit: plan.down_unit,
155
+ upLimit: plan.up_limit,
156
+ upUnit: plan.up_unit,
157
+ });
158
+
159
+ await connection.commit();
160
+
161
+ res.status(201).json({
162
+ payment_id: paymentResult.insertId,
163
+ reference,
164
+ payment_channel: 'tenant_direct_manual',
165
+ verification_source: 'tenant_confirmed',
166
+ counts_toward_balance: false,
167
+ token_id: tokenId,
168
+ token_code: code,
169
+ customer_phone: customer_phone?.trim() || null,
170
+ amount: parseFloat(plan.price),
171
+ device_id: device.id,
172
+ device_name: device.name,
173
+ plan_id: plan.id,
174
+ plan_name: plan.name,
175
+ duration_seconds: plan.duration_seconds,
176
+ notes: notes?.trim() || null,
177
+ message: 'Token generated successfully',
178
+ });
179
+ } catch (err) {
180
+ await connection.rollback();
181
+ console.error('[manual-sales/create]', err.message);
182
+ res.status(500).json({ error: 'Failed to generate manual token sale' });
183
+ } finally {
184
+ connection.release();
185
+ }
186
+ });
187
+
188
+ module.exports = router;
src/routes/portal.routes.js CHANGED
@@ -30,7 +30,9 @@ router.get('/:siteId', async (req, res) => {
30
 
31
  try {
32
  const device = await db.query(
33
- `SELECT d.*, c.business_name, c.commission_rate
 
 
34
  FROM devices d JOIN clients c ON c.id = d.client_id
35
  WHERE d.omada_site_id = ? LIMIT 1`,
36
  [siteId]
@@ -115,6 +117,14 @@ router.get('/:siteId', async (req, res) => {
115
  businessName: device.business_name,
116
  deviceName: device.name,
117
  customize,
 
 
 
 
 
 
 
 
118
  };
119
 
120
  // Custom portal HTML?
 
30
 
31
  try {
32
  const device = await db.query(
33
+ `SELECT d.*, c.business_name, c.commission_rate,
34
+ c.payment_display_name, c.payment_phone, c.payment_provider,
35
+ c.payment_instructions, c.payment_qr_image_url, c.manual_sales_enabled
36
  FROM devices d JOIN clients c ON c.id = d.client_id
37
  WHERE d.omada_site_id = ? LIMIT 1`,
38
  [siteId]
 
117
  businessName: device.business_name,
118
  deviceName: device.name,
119
  customize,
120
+ directPayment: {
121
+ enabled: Boolean(device.manual_sales_enabled && device.payment_display_name && device.payment_phone && device.payment_provider),
122
+ displayName: device.payment_display_name || null,
123
+ phone: device.payment_phone || null,
124
+ provider: device.payment_provider || null,
125
+ instructions: device.payment_instructions || null,
126
+ qrImageUrl: device.payment_qr_image_url || null,
127
+ },
128
  };
129
 
130
  // Custom portal HTML?
src/routes/sessions.routes.js CHANGED
@@ -139,6 +139,9 @@ router.post('/paid-disconnected/:tokenId/message', async (req, res) => {
139
  if (!token) {
140
  return res.status(404).json({ error: 'Paid disconnected client not found' });
141
  }
 
 
 
142
 
143
  const sent = await sms.sendSMS(token.payment_phone, text);
144
  if (!sent) {
 
139
  if (!token) {
140
  return res.status(404).json({ error: 'Paid disconnected client not found' });
141
  }
142
+ if (!token.payment_phone) {
143
+ return res.status(400).json({ error: 'This client has no phone number recorded for messaging' });
144
+ }
145
 
146
  const sent = await sms.sendSMS(token.payment_phone, text);
147
  if (!sent) {
src/utils/migrate.js CHANGED
@@ -78,6 +78,24 @@ const migrations = [
78
  // 008 — Add in-app alert notification preferences to existing installs
79
  `ALTER TABLE client_notification_preferences ADD COLUMN purchase_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER trial_granted`,
80
  `ALTER TABLE client_notification_preferences ADD COLUMN system_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER purchase_alerts`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  ];
82
 
83
  async function runMigrations() {
@@ -89,7 +107,7 @@ async function runMigrations() {
89
  const col = sql.match(/(?:ADD|MODIFY) COLUMN (\w+)/i)?.[1] ?? sql.slice(0, 60);
90
  console.log(`[migrate] Applied: ${col}`);
91
  } catch (err) {
92
- if (err.errno === 1060) {
93
  // Column already exists — nothing to do
94
  } else {
95
  console.error('[migrate] Migration failed:', err.message);
 
78
  // 008 — Add in-app alert notification preferences to existing installs
79
  `ALTER TABLE client_notification_preferences ADD COLUMN purchase_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER trial_granted`,
80
  `ALTER TABLE client_notification_preferences ADD COLUMN system_alerts TINYINT(1) NOT NULL DEFAULT 1 AFTER purchase_alerts`,
81
+
82
+ // 009 — Tenant direct-payment settings on clients
83
+ `ALTER TABLE clients ADD COLUMN payment_display_name VARCHAR(100) DEFAULT NULL AFTER phone`,
84
+ `ALTER TABLE clients ADD COLUMN payment_phone VARCHAR(20) DEFAULT NULL AFTER payment_display_name`,
85
+ `ALTER TABLE clients ADD COLUMN payment_provider VARCHAR(32) DEFAULT NULL AFTER payment_phone`,
86
+ `ALTER TABLE clients ADD COLUMN payment_instructions VARCHAR(255) DEFAULT NULL AFTER payment_provider`,
87
+ `ALTER TABLE clients ADD COLUMN payment_qr_image_url VARCHAR(512) DEFAULT NULL AFTER payment_instructions`,
88
+ `ALTER TABLE clients ADD COLUMN manual_sales_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_qr_image_url`,
89
+
90
+ // 010 — Manual/direct payment tracking fields on payments
91
+ `ALTER TABLE payments MODIFY COLUMN phone VARCHAR(20) NULL`,
92
+ `ALTER TABLE payments MODIFY COLUMN phone_provider ENUM('mpesa','selcom','airtel','tigo','halopesa','bank','other') NOT NULL DEFAULT 'mpesa'`,
93
+ `ALTER TABLE payments ADD COLUMN payment_channel ENUM('snippe','tenant_direct_manual') NOT NULL DEFAULT 'snippe' AFTER phone_provider`,
94
+ `ALTER TABLE payments ADD COLUMN verification_source ENUM('webhook','tenant_confirmed') DEFAULT NULL AFTER payment_channel`,
95
+ `ALTER TABLE payments ADD COLUMN counts_toward_balance TINYINT(1) NOT NULL DEFAULT 1 AFTER verification_source`,
96
+ `ALTER TABLE payments ADD COLUMN confirmed_by_client_id INT UNSIGNED DEFAULT NULL AFTER counts_toward_balance`,
97
+ `ALTER TABLE payments ADD COLUMN confirmed_at TIMESTAMP NULL DEFAULT NULL AFTER confirmed_by_client_id`,
98
+ `ALTER TABLE payments ADD COLUMN notes VARCHAR(255) DEFAULT NULL AFTER confirmed_at`,
99
  ];
100
 
101
  async function runMigrations() {
 
107
  const col = sql.match(/(?:ADD|MODIFY) COLUMN (\w+)/i)?.[1] ?? sql.slice(0, 60);
108
  console.log(`[migrate] Applied: ${col}`);
109
  } catch (err) {
110
+ if (err.errno === 1060 || err.errno === 1091) {
111
  // Column already exists — nothing to do
112
  } else {
113
  console.error('[migrate] Migration failed:', err.message);
src/utils/tokenSales.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { generateCode } = require('./generateCode');
2
+
3
+ async function createAccessTokenForPayment({
4
+ connection,
5
+ paymentId,
6
+ clientId,
7
+ deviceId,
8
+ planId,
9
+ durationSeconds,
10
+ downLimit,
11
+ downUnit,
12
+ upLimit,
13
+ upUnit,
14
+ }) {
15
+ if (!connection) {
16
+ throw new Error('Database connection is required');
17
+ }
18
+
19
+ let lastError = null;
20
+
21
+ for (let attempt = 0; attempt < 5; attempt += 1) {
22
+ const code = generateCode();
23
+
24
+ try {
25
+ const [insertResult] = await connection.execute(
26
+ `INSERT INTO access_tokens
27
+ (client_id, device_id, plan_id, payment_id, code, duration_seconds,
28
+ down_limit, down_unit, up_limit, up_unit, status)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
30
+ [
31
+ clientId,
32
+ deviceId,
33
+ planId,
34
+ paymentId,
35
+ code,
36
+ durationSeconds,
37
+ downLimit,
38
+ downUnit,
39
+ upLimit,
40
+ upUnit,
41
+ ]
42
+ );
43
+
44
+ await connection.execute(
45
+ `UPDATE payments SET access_token_code = ? WHERE id = ?`,
46
+ [code, paymentId]
47
+ );
48
+
49
+ return {
50
+ tokenId: insertResult.insertId,
51
+ code,
52
+ };
53
+ } catch (err) {
54
+ lastError = err;
55
+ if (err.code !== 'ER_DUP_ENTRY') throw err;
56
+ }
57
+ }
58
+
59
+ throw lastError || new Error('Failed to generate a unique token code');
60
+ }
61
+
62
+ module.exports = { createAccessTokenForPayment };
src/views/screens/portal.ejs CHANGED
@@ -77,6 +77,27 @@
77
  font-weight: 500;
78
  color: var(--text-secondary);
79
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  .signal-mark {
81
  display: inline-block;
82
  width: 28px;
@@ -325,6 +346,141 @@
325
  letter-spacing: 0.1em;
326
  color: var(--text-disabled);
327
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
  /* ── Link Button ── */
330
  .link-btn {
@@ -479,6 +635,12 @@
479
  <% } %>
480
  <h1><%= portal.customize.displayName %></h1>
481
  <p><%= portal.customize.welcomeText %></p>
 
 
 
 
 
 
482
  </div>
483
 
484
  <!-- SCREEN 1: Plan selection & phone -->
@@ -509,7 +671,46 @@
509
  <% }); %>
510
  </div>
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  <div class="divider"><span>Pay via Mobile Money</span></div>
 
513
 
514
  <div class="input-wrap">
515
  <label class="field-label" for="phone-input">Phone Number</label>
@@ -529,6 +730,11 @@
529
 
530
  <div class="divider"><span>or</span></div>
531
  <span class="link-btn" id="show-code-entry">Enter an existing code</span>
 
 
 
 
 
532
  <% if (portal.customize.supportPhone) { %>
533
  <p style="text-align:center;font-size:11px;color:var(--text-disabled);margin-top:16px;">
534
  Need help? Call <a href="tel:<%= portal.customize.supportPhone %>" style="color:var(--blue);text-decoration:none;font-weight:600;"><%= portal.customize.supportPhone %></a>
 
77
  font-weight: 500;
78
  color: var(--text-secondary);
79
  }
80
+ .portal-kicker {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 8px;
84
+ margin-top: 14px;
85
+ padding: 7px 11px;
86
+ border-radius: 999px;
87
+ background: var(--nest);
88
+ color: var(--text-primary);
89
+ font-size: 10px;
90
+ font-weight: 700;
91
+ text-transform: uppercase;
92
+ letter-spacing: 0.12em;
93
+ }
94
+ .portal-kicker .dot {
95
+ width: 7px;
96
+ height: 7px;
97
+ border-radius: 50%;
98
+ background: var(--blue);
99
+ box-shadow: 0 0 0 4px rgba(0, 84, 203, 0.10);
100
+ }
101
  .signal-mark {
102
  display: inline-block;
103
  width: 28px;
 
346
  letter-spacing: 0.1em;
347
  color: var(--text-disabled);
348
  }
349
+ .payment-note {
350
+ margin-top: 14px;
351
+ font-size: 11px;
352
+ line-height: 1.55;
353
+ color: var(--text-secondary);
354
+ }
355
+ .manual-pay-card {
356
+ margin: 18px 0 6px;
357
+ padding: 18px;
358
+ background:
359
+ radial-gradient(circle at top right, rgba(0,84,203,0.10), transparent 38%),
360
+ linear-gradient(180deg, #111111 0%, #1A1A1A 100%);
361
+ border-radius: 18px;
362
+ color: #FFFFFF;
363
+ box-shadow: 0 18px 38px rgba(0,0,0,0.14);
364
+ overflow: hidden;
365
+ position: relative;
366
+ }
367
+ .manual-pay-card::after {
368
+ content: '';
369
+ position: absolute;
370
+ inset: auto -20% -40% 45%;
371
+ height: 120px;
372
+ background: radial-gradient(circle, rgba(255,255,255,0.08), transparent 70%);
373
+ pointer-events: none;
374
+ }
375
+ .manual-pay-head {
376
+ display: flex;
377
+ justify-content: space-between;
378
+ align-items: flex-start;
379
+ gap: 14px;
380
+ margin-bottom: 14px;
381
+ }
382
+ .manual-pay-head h3 {
383
+ font-size: 17px;
384
+ font-weight: 800;
385
+ letter-spacing: -0.03em;
386
+ margin-bottom: 4px;
387
+ }
388
+ .manual-pay-head p {
389
+ font-size: 12px;
390
+ line-height: 1.5;
391
+ color: rgba(255,255,255,0.74);
392
+ }
393
+ .manual-pay-badge {
394
+ flex-shrink: 0;
395
+ padding: 7px 10px;
396
+ border-radius: 999px;
397
+ background: rgba(255,255,255,0.10);
398
+ border: 1px solid rgba(255,255,255,0.12);
399
+ font-size: 10px;
400
+ font-weight: 800;
401
+ text-transform: uppercase;
402
+ letter-spacing: 0.12em;
403
+ color: #FFFFFF;
404
+ }
405
+ .manual-pay-details {
406
+ display: grid;
407
+ gap: 10px;
408
+ margin-bottom: 14px;
409
+ }
410
+ .manual-pay-row {
411
+ display: flex;
412
+ justify-content: space-between;
413
+ gap: 12px;
414
+ align-items: flex-start;
415
+ padding-bottom: 10px;
416
+ border-bottom: 1px solid rgba(255,255,255,0.10);
417
+ }
418
+ .manual-pay-row:last-child {
419
+ border-bottom: none;
420
+ padding-bottom: 0;
421
+ }
422
+ .manual-pay-key {
423
+ font-size: 10px;
424
+ font-weight: 700;
425
+ text-transform: uppercase;
426
+ letter-spacing: 0.14em;
427
+ color: rgba(255,255,255,0.56);
428
+ }
429
+ .manual-pay-val {
430
+ text-align: right;
431
+ font-size: 14px;
432
+ font-weight: 700;
433
+ line-height: 1.4;
434
+ color: #FFFFFF;
435
+ }
436
+ .manual-pay-val.phone {
437
+ font-size: 19px;
438
+ letter-spacing: 0.04em;
439
+ }
440
+ .manual-pay-foot {
441
+ margin-top: 14px;
442
+ padding-top: 14px;
443
+ border-top: 1px solid rgba(255,255,255,0.10);
444
+ }
445
+ .manual-pay-foot p {
446
+ font-size: 12px;
447
+ line-height: 1.6;
448
+ color: rgba(255,255,255,0.78);
449
+ }
450
+ .manual-pay-steps {
451
+ display: grid;
452
+ gap: 8px;
453
+ margin-top: 12px;
454
+ }
455
+ .manual-pay-step {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 10px;
459
+ font-size: 11px;
460
+ font-weight: 600;
461
+ color: rgba(255,255,255,0.9);
462
+ }
463
+ .manual-pay-step .step-no {
464
+ width: 22px;
465
+ height: 22px;
466
+ display: inline-flex;
467
+ align-items: center;
468
+ justify-content: center;
469
+ border-radius: 50%;
470
+ background: rgba(255,255,255,0.12);
471
+ font-size: 10px;
472
+ font-weight: 800;
473
+ letter-spacing: 0.04em;
474
+ }
475
+ .quick-access-note {
476
+ margin-top: 14px;
477
+ padding: 12px 14px;
478
+ border-radius: 12px;
479
+ background: var(--nest);
480
+ font-size: 11px;
481
+ line-height: 1.6;
482
+ color: var(--text-secondary);
483
+ }
484
 
485
  /* ── Link Button ── */
486
  .link-btn {
 
635
  <% } %>
636
  <h1><%= portal.customize.displayName %></h1>
637
  <p><%= portal.customize.welcomeText %></p>
638
+ <% if (portal.directPayment.enabled) { %>
639
+ <div class="portal-kicker">
640
+ <span class="dot"></span>
641
+ <span>Direct payment available on this hotspot</span>
642
+ </div>
643
+ <% } %>
644
  </div>
645
 
646
  <!-- SCREEN 1: Plan selection & phone -->
 
671
  <% }); %>
672
  </div>
673
 
674
+ <% if (portal.directPayment.enabled) { %>
675
+ <div class="manual-pay-card">
676
+ <div class="manual-pay-head">
677
+ <div>
678
+ <h3>Pay the business directly</h3>
679
+ <p>Use the number below, then show payment confirmation and get your WiFi code from the attendant.</p>
680
+ </div>
681
+ <div class="manual-pay-badge"><%= String(portal.directPayment.provider || '').toUpperCase() %></div>
682
+ </div>
683
+
684
+ <div class="manual-pay-details">
685
+ <div class="manual-pay-row">
686
+ <span class="manual-pay-key">Pay To</span>
687
+ <span class="manual-pay-val"><%= portal.directPayment.displayName %></span>
688
+ </div>
689
+ <div class="manual-pay-row">
690
+ <span class="manual-pay-key">Number</span>
691
+ <span class="manual-pay-val phone"><%= portal.directPayment.phone %></span>
692
+ </div>
693
+ <% if (portal.directPayment.instructions) { %>
694
+ <div class="manual-pay-row">
695
+ <span class="manual-pay-key">Instructions</span>
696
+ <span class="manual-pay-val"><%= portal.directPayment.instructions %></span>
697
+ </div>
698
+ <% } %>
699
+ </div>
700
+
701
+ <div class="manual-pay-foot">
702
+ <p>The attendant confirms payment from their own phone and generates your token from the dashboard.</p>
703
+ <div class="manual-pay-steps">
704
+ <div class="manual-pay-step"><span class="step-no">1</span><span>Choose your plan from this page.</span></div>
705
+ <div class="manual-pay-step"><span class="step-no">2</span><span>Pay the business using the number above.</span></div>
706
+ <div class="manual-pay-step"><span class="step-no">3</span><span>Use the WiFi code you receive to connect.</span></div>
707
+ </div>
708
+ </div>
709
+ </div>
710
+ <% } %>
711
+
712
  <div class="divider"><span>Pay via Mobile Money</span></div>
713
+ <p class="payment-note">Prefer instant activation? Enter your own number below to pay online and receive your WiFi code automatically.</p>
714
 
715
  <div class="input-wrap">
716
  <label class="field-label" for="phone-input">Phone Number</label>
 
730
 
731
  <div class="divider"><span>or</span></div>
732
  <span class="link-btn" id="show-code-entry">Enter an existing code</span>
733
+ <% if (portal.directPayment.enabled) { %>
734
+ <div class="quick-access-note">
735
+ Already paid the business directly? Ask for your token, then use <strong>Enter an existing code</strong> to connect here.
736
+ </div>
737
+ <% } %>
738
  <% if (portal.customize.supportPhone) { %>
739
  <p style="text-align:center;font-size:11px;color:var(--text-disabled);margin-top:16px;">
740
  Need help? Call <a href="tel:<%= portal.customize.supportPhone %>" style="color:var(--blue);text-decoration:none;font-weight:600;"><%= portal.customize.supportPhone %></a>