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

Add portal payment display mode controls

Browse files
API_DOCUMENTATION.md CHANGED
@@ -272,7 +272,8 @@ Get the tenant's direct-payment receiving settings used for manual token sales.
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
 
@@ -286,6 +287,11 @@ If `manual_sales_enabled` is `true`, the account must have:
286
  - `payment_phone`
287
  - `payment_provider`
288
 
 
 
 
 
 
289
  **Auth:** Required
290
 
291
  **Body:**
@@ -296,7 +302,8 @@ If `manual_sales_enabled` is `true`, the account must have:
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
 
@@ -309,6 +316,7 @@ If `manual_sales_enabled` is `true`, the account must have:
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
  ```
 
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
+ "portal_payment_mode": "both"
277
  }
278
  ```
279
 
 
287
  - `payment_phone`
288
  - `payment_provider`
289
 
290
+ `portal_payment_mode` controls what the captive portal shows:
291
+ - `online_only`
292
+ - `direct_only`
293
+ - `both`
294
+
295
  **Auth:** Required
296
 
297
  **Body:**
 
302
  "payment_provider": "selcom",
303
  "payment_instructions": "Pay this number, then confirm from the dashboard.",
304
  "payment_qr_image_url": null,
305
+ "manual_sales_enabled": true,
306
+ "portal_payment_mode": "both"
307
  }
308
  ```
309
 
 
316
  "payment_instructions": "Pay this number, then confirm from the dashboard.",
317
  "payment_qr_image_url": null,
318
  "manual_sales_enabled": true,
319
+ "portal_payment_mode": "both",
320
  "message": "Payment settings updated"
321
  }
322
  ```
schema.sql CHANGED
@@ -23,6 +23,7 @@ CREATE TABLE `clients` (
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,
 
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
+ `portal_payment_mode` VARCHAR(24) NOT NULL DEFAULT 'both',
27
  `password_hash` VARCHAR(255) NOT NULL,
28
  `commission_rate` DECIMAL(5,4) NOT NULL DEFAULT 0.0500,
29
  `balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
src/routes/auth.routes.js CHANGED
@@ -13,6 +13,7 @@ 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) => {
@@ -306,6 +307,7 @@ router.get('/me', requireAuth, async (req, res) => {
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]
@@ -323,7 +325,8 @@ 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
  );
@@ -337,6 +340,7 @@ router.get('/payment-settings', requireAuth, async (req, res) => {
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);
@@ -353,6 +357,7 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
353
  payment_instructions,
354
  payment_qr_image_url,
355
  manual_sales_enabled,
 
356
  } = req.body;
357
 
358
  const updates = [];
@@ -409,6 +414,14 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
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
  }
@@ -417,7 +430,7 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
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
  );
@@ -429,6 +442,7 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
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) {
@@ -446,7 +460,8 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
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
  );
@@ -458,6 +473,7 @@ router.patch('/payment-settings', requireAuth, async (req, res) => {
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) {
 
13
  const { loginLimiter, registerLimiter, forgotPasswordLimiter, phoneOtpLimiter } = require('../middleware/rateLimiter');
14
 
15
  const ALLOWED_PAYMENT_PROVIDERS = ['mpesa', 'selcom', 'airtel', 'tigo', 'halopesa', 'bank', 'other'];
16
+ const ALLOWED_PORTAL_PAYMENT_MODES = ['online_only', 'direct_only', 'both'];
17
 
18
  // POST /api/auth/register
19
  router.post('/register', registerLimiter, async (req, res) => {
 
307
  `SELECT id, business_name, contact_name, email, phone,
308
  payment_display_name, payment_phone, payment_provider,
309
  payment_instructions, payment_qr_image_url, manual_sales_enabled,
310
+ portal_payment_mode,
311
  balance, total_earned, total_withdrawn, created_at
312
  FROM clients WHERE id = ?`,
313
  [req.client.id]
 
325
  try {
326
  const settings = await db.queryOne(
327
  `SELECT payment_display_name, payment_phone, payment_provider,
328
+ payment_instructions, payment_qr_image_url, manual_sales_enabled,
329
+ portal_payment_mode
330
  FROM clients WHERE id = ?`,
331
  [req.client.id]
332
  );
 
340
  payment_instructions: settings.payment_instructions,
341
  payment_qr_image_url: settings.payment_qr_image_url,
342
  manual_sales_enabled: Boolean(settings.manual_sales_enabled),
343
+ portal_payment_mode: settings.portal_payment_mode || 'both',
344
  });
345
  } catch (err) {
346
  console.error('[auth/payment-settings GET]', err.message);
 
357
  payment_instructions,
358
  payment_qr_image_url,
359
  manual_sales_enabled,
360
+ portal_payment_mode,
361
  } = req.body;
362
 
363
  const updates = [];
 
414
  values.push(manual_sales_enabled ? 1 : 0);
415
  }
416
 
417
+ if (portal_payment_mode !== undefined) {
418
+ if (typeof portal_payment_mode !== 'string' || !ALLOWED_PORTAL_PAYMENT_MODES.includes(portal_payment_mode)) {
419
+ return res.status(400).json({ error: `portal_payment_mode must be one of: ${ALLOWED_PORTAL_PAYMENT_MODES.join(', ')}` });
420
+ }
421
+ updates.push('portal_payment_mode = ?');
422
+ values.push(portal_payment_mode);
423
+ }
424
+
425
  if (!updates.length) {
426
  return res.status(400).json({ error: 'No payment setting updates provided' });
427
  }
 
430
 
431
  try {
432
  const current = await db.queryOne(
433
+ `SELECT payment_display_name, payment_phone, payment_provider, manual_sales_enabled, portal_payment_mode
434
  FROM clients WHERE id = ?`,
435
  [req.client.id]
436
  );
 
442
  payment_phone: payment_phone !== undefined ? (payment_phone?.trim() || null) : current.payment_phone,
443
  payment_provider: payment_provider !== undefined ? (payment_provider || null) : current.payment_provider,
444
  manual_sales_enabled: manual_sales_enabled !== undefined ? manual_sales_enabled : Boolean(current.manual_sales_enabled),
445
+ portal_payment_mode: portal_payment_mode !== undefined ? portal_payment_mode : (current.portal_payment_mode || 'both'),
446
  };
447
 
448
  if (enablingManualSales || merged.manual_sales_enabled) {
 
460
 
461
  const updated = await db.queryOne(
462
  `SELECT payment_display_name, payment_phone, payment_provider,
463
+ payment_instructions, payment_qr_image_url, manual_sales_enabled,
464
+ portal_payment_mode
465
  FROM clients WHERE id = ?`,
466
  [req.client.id]
467
  );
 
473
  payment_instructions: updated.payment_instructions,
474
  payment_qr_image_url: updated.payment_qr_image_url,
475
  manual_sales_enabled: Boolean(updated.manual_sales_enabled),
476
+ portal_payment_mode: updated.portal_payment_mode || 'both',
477
  message: 'Payment settings updated',
478
  });
479
  } catch (err) {
src/routes/portal.routes.js CHANGED
@@ -108,6 +108,18 @@ router.get('/:siteId', async (req, res) => {
108
  bgColor: portalSettings?.bg_color || '#F9F9F9',
109
  };
110
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  const portalData = {
112
  siteId,
113
  clientMac,
@@ -118,13 +130,16 @@ router.get('/:siteId', async (req, res) => {
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?
 
108
  bgColor: portalSettings?.bg_color || '#F9F9F9',
109
  };
110
 
111
+ const directPaymentAvailable = Boolean(
112
+ device.manual_sales_enabled &&
113
+ device.payment_display_name &&
114
+ device.payment_phone &&
115
+ device.payment_provider
116
+ );
117
+ const portalPaymentMode = device.portal_payment_mode || 'both';
118
+ const showDirectPayment = directPaymentAvailable && (
119
+ portalPaymentMode === 'both' || portalPaymentMode === 'direct_only'
120
+ );
121
+ const showOnlinePayment = portalPaymentMode !== 'direct_only' || !directPaymentAvailable;
122
+
123
  const portalData = {
124
  siteId,
125
  clientMac,
 
130
  deviceName: device.name,
131
  customize,
132
  directPayment: {
133
+ enabled: showDirectPayment,
134
+ available: directPaymentAvailable,
135
  displayName: device.payment_display_name || null,
136
  phone: device.payment_phone || null,
137
  provider: device.payment_provider || null,
138
  instructions: device.payment_instructions || null,
139
  qrImageUrl: device.payment_qr_image_url || null,
140
  },
141
+ portalPaymentMode,
142
+ showOnlinePayment,
143
  };
144
 
145
  // Custom portal HTML?
src/utils/migrate.js CHANGED
@@ -86,6 +86,7 @@ const migrations = [
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`,
 
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
+ `ALTER TABLE clients ADD COLUMN portal_payment_mode VARCHAR(24) NOT NULL DEFAULT 'both' AFTER manual_sales_enabled`,
90
 
91
  // 010 — Manual/direct payment tracking fields on payments
92
  `ALTER TABLE payments MODIFY COLUMN phone VARCHAR(20) NULL`,
src/views/screens/portal.ejs CHANGED
@@ -638,7 +638,7 @@
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>
@@ -709,10 +709,10 @@
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>
717
  <div class="phone-group">
718
  <div class="phone-prefix">
@@ -726,9 +726,15 @@
726
  </div>
727
 
728
  <div class="error-msg" id="select-error"></div>
729
- <button class="btn-primary" id="pay-btn" disabled>Select a plan to continue</button>
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">
 
638
  <% if (portal.directPayment.enabled) { %>
639
  <div class="portal-kicker">
640
  <span class="dot"></span>
641
+ <span><%= portal.showOnlinePayment ? 'Direct payment also available' : 'Direct payment only on this hotspot' %></span>
642
  </div>
643
  <% } %>
644
  </div>
 
709
  </div>
710
  <% } %>
711
 
712
+ <div class="divider" <% if (!portal.showOnlinePayment) { %>style="display:none;"<% } %>><span>Pay via Mobile Money</span></div>
713
+ <p class="payment-note" <% if (!portal.showOnlinePayment) { %>style="display:none;"<% } %>>Prefer instant activation? Enter your own number below to pay online and receive your WiFi code automatically.</p>
714
 
715
+ <div class="input-wrap" <% if (!portal.showOnlinePayment) { %>style="display:none;"<% } %>>
716
  <label class="field-label" for="phone-input">Phone Number</label>
717
  <div class="phone-group">
718
  <div class="phone-prefix">
 
726
  </div>
727
 
728
  <div class="error-msg" id="select-error"></div>
729
+ <button class="btn-primary" id="pay-btn" disabled <% if (!portal.showOnlinePayment) { %>style="display:none;"<% } %>>Select a plan to continue</button>
730
 
731
+ <% if (!portal.showOnlinePayment) { %>
732
+ <div class="quick-access-note" style="margin-top:18px;">
733
+ This hotspot is currently configured for direct business payment only. Pay the business above and use the WiFi code you receive.
734
+ </div>
735
+ <% } %>
736
+
737
+ <div class="divider"><span><%= portal.showOnlinePayment ? 'or' : 'then' %></span></div>
738
  <span class="link-btn" id="show-code-entry">Enter an existing code</span>
739
  <% if (portal.directPayment.enabled) { %>
740
  <div class="quick-access-note">