Add captive portal free promo claims
Browse files- public/engine.js +23 -5
- src/routes/plans.routes.js +26 -3
- src/routes/portal.routes.js +201 -8
- src/utils/messages.js +4 -0
- src/views/screens/portal.ejs +8 -6
public/engine.js
CHANGED
|
@@ -48,6 +48,7 @@
|
|
| 48 |
// ── State ─────────────────────────────────────────────────────────────────
|
| 49 |
let selectedPlanId = null;
|
| 50 |
let selectedPlanPrice = null;
|
|
|
|
| 51 |
let pollTimer = null;
|
| 52 |
let currentReference = null;
|
| 53 |
let purchaseInFlight = false;
|
|
@@ -64,7 +65,9 @@
|
|
| 64 |
function updatePayBtn() {
|
| 65 |
if (!payBtn) return;
|
| 66 |
if (selectedPlanId && isPhoneValid()) {
|
| 67 |
-
payBtn.textContent =
|
|
|
|
|
|
|
| 68 |
payBtn.disabled = false;
|
| 69 |
} else if (selectedPlanId) {
|
| 70 |
payBtn.textContent = 'Enter your phone number';
|
|
@@ -79,6 +82,7 @@
|
|
| 79 |
radio.addEventListener('change', () => {
|
| 80 |
selectedPlanId = radio.dataset.planId;
|
| 81 |
selectedPlanPrice = radio.dataset.planPrice;
|
|
|
|
| 82 |
updatePayBtn();
|
| 83 |
});
|
| 84 |
});
|
|
@@ -106,24 +110,38 @@
|
|
| 106 |
if (!phone || phone.replace(/\D/g, '').length < 9) {
|
| 107 |
return showError('select-error', 'Enter a valid phone number.');
|
| 108 |
}
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
purchaseInFlight = true;
|
| 111 |
payBtn.disabled = true;
|
| 112 |
showScreen('waiting');
|
| 113 |
|
| 114 |
try {
|
| 115 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
method: 'POST',
|
| 117 |
headers: { 'Content-Type': 'application/json' },
|
| 118 |
-
body: JSON.stringify(
|
| 119 |
});
|
| 120 |
-
const data = await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
if (!res.ok || !data.reference) {
|
| 123 |
showScreen('select');
|
| 124 |
purchaseInFlight = false;
|
| 125 |
payBtn.disabled = false;
|
| 126 |
-
return showError('select-error', data.error || 'Purchase failed. Try again.');
|
| 127 |
}
|
| 128 |
|
| 129 |
currentReference = data.reference;
|
|
|
|
| 48 |
// ── State ─────────────────────────────────────────────────────────────────
|
| 49 |
let selectedPlanId = null;
|
| 50 |
let selectedPlanPrice = null;
|
| 51 |
+
let selectedPlanIsPromo = false;
|
| 52 |
let pollTimer = null;
|
| 53 |
let currentReference = null;
|
| 54 |
let purchaseInFlight = false;
|
|
|
|
| 65 |
function updatePayBtn() {
|
| 66 |
if (!payBtn) return;
|
| 67 |
if (selectedPlanId && isPhoneValid()) {
|
| 68 |
+
payBtn.textContent = selectedPlanIsPromo
|
| 69 |
+
? 'Claim promo'
|
| 70 |
+
: `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
|
| 71 |
payBtn.disabled = false;
|
| 72 |
} else if (selectedPlanId) {
|
| 73 |
payBtn.textContent = 'Enter your phone number';
|
|
|
|
| 82 |
radio.addEventListener('change', () => {
|
| 83 |
selectedPlanId = radio.dataset.planId;
|
| 84 |
selectedPlanPrice = radio.dataset.planPrice;
|
| 85 |
+
selectedPlanIsPromo = radio.dataset.planPromo === '1' || Number(selectedPlanPrice) === 0;
|
| 86 |
updatePayBtn();
|
| 87 |
});
|
| 88 |
});
|
|
|
|
| 110 |
if (!phone || phone.replace(/\D/g, '').length < 9) {
|
| 111 |
return showError('select-error', 'Enter a valid phone number.');
|
| 112 |
}
|
| 113 |
+
if (selectedPlanIsPromo && !P.clientMac) {
|
| 114 |
+
return showError('select-error', 'Open this portal from the WiFi login page on the device you want to connect.');
|
| 115 |
+
}
|
| 116 |
|
| 117 |
purchaseInFlight = true;
|
| 118 |
payBtn.disabled = true;
|
| 119 |
showScreen('waiting');
|
| 120 |
|
| 121 |
try {
|
| 122 |
+
const endpoint = selectedPlanIsPromo ? 'promo' : 'purchase';
|
| 123 |
+
const payload = selectedPlanIsPromo
|
| 124 |
+
? { planId: selectedPlanId, phone, clientMac: P.clientMac }
|
| 125 |
+
: { planId: selectedPlanId, phone };
|
| 126 |
+
const res = await fetch(`/portal/${P.siteId}/${endpoint}`, {
|
| 127 |
method: 'POST',
|
| 128 |
headers: { 'Content-Type': 'application/json' },
|
| 129 |
+
body: JSON.stringify(payload),
|
| 130 |
});
|
| 131 |
+
const data = await readJsonSafe(res);
|
| 132 |
+
|
| 133 |
+
if (selectedPlanIsPromo && res.ok && data.code) {
|
| 134 |
+
purchaseInFlight = false;
|
| 135 |
+
payBtn.disabled = false;
|
| 136 |
+
showCodeEntry(data.code);
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
|
| 140 |
if (!res.ok || !data.reference) {
|
| 141 |
showScreen('select');
|
| 142 |
purchaseInFlight = false;
|
| 143 |
payBtn.disabled = false;
|
| 144 |
+
return showError('select-error', data.error || (selectedPlanIsPromo ? 'Could not issue promo code. Please ask the attendant.' : 'Purchase failed. Try again.'));
|
| 145 |
}
|
| 146 |
|
| 147 |
currentReference = data.reference;
|
src/routes/plans.routes.js
CHANGED
|
@@ -11,6 +11,12 @@ function formatVoucherSyncError(err) {
|
|
| 11 |
return `${code}${err.message || 'Voucher group sync failed'}${detail}`.slice(0, 255);
|
| 12 |
}
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
async function syncPlanVoucherGroup(plan) {
|
| 15 |
const device = await db.queryOne(
|
| 16 |
`SELECT id, omada_site_id FROM devices WHERE id = ? AND client_id = ? LIMIT 1`,
|
|
@@ -171,10 +177,18 @@ router.get('/', async (req, res) => {
|
|
| 171 |
// POST /api/plans
|
| 172 |
router.post('/', async (req, res) => {
|
| 173 |
const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
if (!device_id || !name ||
|
| 176 |
return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
|
| 177 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
try {
|
| 180 |
const device = await db.query(
|
|
@@ -190,7 +204,7 @@ router.post('/', async (req, res) => {
|
|
| 190 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
| 191 |
[
|
| 192 |
req.client.id, device_id, name,
|
| 193 |
-
parseInt(
|
| 194 |
down_limit || 1, down_unit || 2,
|
| 195 |
up_limit || 5, up_unit || 2,
|
| 196 |
display_order || 0,
|
|
@@ -208,6 +222,15 @@ router.post('/', async (req, res) => {
|
|
| 208 |
// PUT /api/plans/:id
|
| 209 |
router.put('/:id', async (req, res) => {
|
| 210 |
const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
try {
|
| 213 |
const plan = await db.query(
|
|
@@ -229,7 +252,7 @@ router.put('/:id', async (req, res) => {
|
|
| 229 |
is_active = COALESCE(?, is_active)
|
| 230 |
WHERE id = ?`,
|
| 231 |
[
|
| 232 |
-
name ?? null, duration_seconds ?
|
| 233 |
down_limit ?? null, down_unit ?? null,
|
| 234 |
up_limit ?? null, up_unit ?? null,
|
| 235 |
display_order !== undefined ? display_order : null,
|
|
|
|
| 11 |
return `${code}${err.message || 'Voucher group sync failed'}${detail}`.slice(0, 255);
|
| 12 |
}
|
| 13 |
|
| 14 |
+
function parsePlanNumber(value) {
|
| 15 |
+
if (value === undefined || value === null || value === '') return null;
|
| 16 |
+
const parsed = Number(value);
|
| 17 |
+
return Number.isFinite(parsed) ? parsed : null;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
async function syncPlanVoucherGroup(plan) {
|
| 21 |
const device = await db.queryOne(
|
| 22 |
`SELECT id, omada_site_id FROM devices WHERE id = ? AND client_id = ? LIMIT 1`,
|
|
|
|
| 177 |
// POST /api/plans
|
| 178 |
router.post('/', async (req, res) => {
|
| 179 |
const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
|
| 180 |
+
const durationValue = parsePlanNumber(duration_seconds);
|
| 181 |
+
const priceValue = parsePlanNumber(price);
|
| 182 |
|
| 183 |
+
if (!device_id || !name || durationValue === null || priceValue === null) {
|
| 184 |
return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
|
| 185 |
}
|
| 186 |
+
if (durationValue <= 0) {
|
| 187 |
+
return res.status(400).json({ error: 'duration_seconds must be greater than 0' });
|
| 188 |
+
}
|
| 189 |
+
if (priceValue < 0) {
|
| 190 |
+
return res.status(400).json({ error: 'price cannot be negative' });
|
| 191 |
+
}
|
| 192 |
|
| 193 |
try {
|
| 194 |
const device = await db.query(
|
|
|
|
| 204 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
| 205 |
[
|
| 206 |
req.client.id, device_id, name,
|
| 207 |
+
parseInt(durationValue, 10), priceValue,
|
| 208 |
down_limit || 1, down_unit || 2,
|
| 209 |
up_limit || 5, up_unit || 2,
|
| 210 |
display_order || 0,
|
|
|
|
| 222 |
// PUT /api/plans/:id
|
| 223 |
router.put('/:id', async (req, res) => {
|
| 224 |
const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
|
| 225 |
+
const durationValue = parsePlanNumber(duration_seconds);
|
| 226 |
+
const priceValue = parsePlanNumber(price);
|
| 227 |
+
|
| 228 |
+
if (duration_seconds !== undefined && (durationValue === null || durationValue <= 0)) {
|
| 229 |
+
return res.status(400).json({ error: 'duration_seconds must be greater than 0' });
|
| 230 |
+
}
|
| 231 |
+
if (price !== undefined && (priceValue === null || priceValue < 0)) {
|
| 232 |
+
return res.status(400).json({ error: 'price cannot be negative' });
|
| 233 |
+
}
|
| 234 |
|
| 235 |
try {
|
| 236 |
const plan = await db.query(
|
|
|
|
| 252 |
is_active = COALESCE(?, is_active)
|
| 253 |
WHERE id = ?`,
|
| 254 |
[
|
| 255 |
+
name ?? null, duration_seconds !== undefined ? parseInt(durationValue, 10) : null, price !== undefined ? priceValue : null,
|
| 256 |
down_limit ?? null, down_unit ?? null,
|
| 257 |
up_limit ?? null, up_unit ?? null,
|
| 258 |
display_order !== undefined ? display_order : null,
|
src/routes/portal.routes.js
CHANGED
|
@@ -5,7 +5,8 @@ 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
|
|
|
|
| 9 |
const { v4: uuidv4 } = require('uuid');
|
| 10 |
const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
|
| 11 |
const { alertAdmin } = require('../utils/adminAlert');
|
|
@@ -17,6 +18,27 @@ const { alertWifiClient } = require('../utils/tokenClientAlerts');
|
|
| 17 |
const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
|
| 18 |
const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const omadaPortalErrorMap = {
|
| 21 |
'-41501': 'Failed to authenticate.',
|
| 22 |
'-41502': 'Voucher code is incorrect.',
|
|
@@ -190,13 +212,18 @@ async function recordOmadaVoucherUse({ siteId, code, mac }) {
|
|
| 190 |
}
|
| 191 |
|
| 192 |
if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
|
| 193 |
-
|
|
|
|
| 194 |
siteId,
|
| 195 |
code,
|
| 196 |
tokenId: token.id,
|
| 197 |
-
|
| 198 |
acceptedMac: mac,
|
| 199 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
if (!expiresAt || expiresAt < now) {
|
|
@@ -601,6 +628,172 @@ router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
|
|
| 601 |
});
|
| 602 |
|
| 603 |
// ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
router.get('/:siteId/check/:reference', async (req, res) => {
|
| 605 |
try {
|
| 606 |
const payment = await db.query(
|
|
@@ -836,14 +1029,14 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
|
|
| 836 |
return res.status(410).json({ error: 'Code has expired' });
|
| 837 |
}
|
| 838 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
if (token.status === 'active') {
|
| 840 |
trace.step('active_token_validation_start');
|
| 841 |
|
| 842 |
-
if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
|
| 843 |
-
trace.step('token_rejected_locked_mac', { lockedMac: token.locked_mac });
|
| 844 |
-
return res.status(403).json({ error: 'Code is locked to another device' });
|
| 845 |
-
}
|
| 846 |
-
|
| 847 |
if (token.expires_at && new Date(token.expires_at) < new Date()) {
|
| 848 |
await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
|
| 849 |
trace.step('token_marked_expired');
|
|
|
|
| 5 |
const omada = require('../services/omada');
|
| 6 |
const snippe = require('../services/snippe');
|
| 7 |
const sms = require('../services/sms');
|
| 8 |
+
const messages = require('../utils/messages');
|
| 9 |
+
const { createAccessTokenForPayment } = require('../utils/tokenSales');
|
| 10 |
const { v4: uuidv4 } = require('uuid');
|
| 11 |
const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
|
| 12 |
const { alertAdmin } = require('../utils/adminAlert');
|
|
|
|
| 18 |
const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
|
| 19 |
const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
|
| 20 |
|
| 21 |
+
function formatDuration(seconds) {
|
| 22 |
+
if (seconds >= 86400) {
|
| 23 |
+
const days = Math.round(seconds / 86400);
|
| 24 |
+
return `${days} day${days === 1 ? '' : 's'}`;
|
| 25 |
+
}
|
| 26 |
+
if (seconds >= 3600) {
|
| 27 |
+
const hours = Math.round(seconds / 3600);
|
| 28 |
+
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
| 29 |
+
}
|
| 30 |
+
const mins = Math.round(seconds / 60);
|
| 31 |
+
return `${mins} min`;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function normalizePhoneForPromo(phone) {
|
| 35 |
+
const digits = String(phone || '').replace(/\D/g, '');
|
| 36 |
+
if (digits.length === 9) return `255${digits}`;
|
| 37 |
+
if (digits.length === 10 && digits.startsWith('0')) return `255${digits.slice(1)}`;
|
| 38 |
+
if (digits.length === 12 && digits.startsWith('255')) return digits;
|
| 39 |
+
return digits;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
const omadaPortalErrorMap = {
|
| 43 |
'-41501': 'Failed to authenticate.',
|
| 44 |
'-41502': 'Voucher code is incorrect.',
|
|
|
|
| 212 |
}
|
| 213 |
|
| 214 |
if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
|
| 215 |
+
await conn.rollback();
|
| 216 |
+
console.warn('[portal/omada-auth-record] Omada accepted token for a different MAC; rejecting DB sync:', {
|
| 217 |
siteId,
|
| 218 |
code,
|
| 219 |
tokenId: token.id,
|
| 220 |
+
lockedMac: token.locked_mac,
|
| 221 |
acceptedMac: mac,
|
| 222 |
});
|
| 223 |
+
const err = new Error('Code is locked to another device');
|
| 224 |
+
err.status = 403;
|
| 225 |
+
err.code = 'TOKEN_LOCKED_TO_ANOTHER_DEVICE';
|
| 226 |
+
throw err;
|
| 227 |
}
|
| 228 |
|
| 229 |
if (!expiresAt || expiresAt < now) {
|
|
|
|
| 628 |
});
|
| 629 |
|
| 630 |
// ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
|
| 631 |
+
// POST /portal/:siteId/promo - claim a free promo voucher instantly
|
| 632 |
+
router.post('/:siteId/promo', purchaseLimiter, async (req, res) => {
|
| 633 |
+
const { siteId } = req.params;
|
| 634 |
+
const { planId, phone, clientMac } = req.body;
|
| 635 |
+
const cleanPhone = normalizePhoneForPromo(phone);
|
| 636 |
+
const mac = normalizePortalMac(clientMac);
|
| 637 |
+
|
| 638 |
+
if (!planId || !cleanPhone || cleanPhone.length < 12 || !mac) {
|
| 639 |
+
return res.status(400).json({ error: 'planId, phone and clientMac required' });
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
const connection = await db.pool.getConnection();
|
| 643 |
+
|
| 644 |
+
try {
|
| 645 |
+
await connection.beginTransaction();
|
| 646 |
+
|
| 647 |
+
const [deviceRows] = await connection.execute(
|
| 648 |
+
`SELECT d.*, c.business_name, c.commission_rate
|
| 649 |
+
FROM devices d JOIN clients c ON c.id = d.client_id
|
| 650 |
+
WHERE d.omada_site_id = ? LIMIT 1`,
|
| 651 |
+
[siteId]
|
| 652 |
+
);
|
| 653 |
+
const device = deviceRows[0];
|
| 654 |
+
|
| 655 |
+
if (!device) {
|
| 656 |
+
await connection.rollback();
|
| 657 |
+
return res.status(404).json({ error: 'Device not found' });
|
| 658 |
+
}
|
| 659 |
+
if (!['trial', 'active'].includes(device.billing_status)) {
|
| 660 |
+
await connection.rollback();
|
| 661 |
+
return res.status(403).json({ error: 'WiFi service unavailable - billing suspended' });
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
const [planRows] = await connection.execute(
|
| 665 |
+
`SELECT *
|
| 666 |
+
FROM wifi_plans
|
| 667 |
+
WHERE id = ? AND device_id = ? AND is_active = 1 LIMIT 1
|
| 668 |
+
FOR UPDATE`,
|
| 669 |
+
[planId, device.id]
|
| 670 |
+
);
|
| 671 |
+
const plan = planRows[0];
|
| 672 |
+
|
| 673 |
+
if (!plan) {
|
| 674 |
+
await connection.rollback();
|
| 675 |
+
return res.status(404).json({ error: 'Promo is not available right now.' });
|
| 676 |
+
}
|
| 677 |
+
if (Number(plan.price) !== 0) {
|
| 678 |
+
await connection.rollback();
|
| 679 |
+
return res.status(400).json({ error: 'Selected plan is not a free promo.' });
|
| 680 |
+
}
|
| 681 |
+
if (!plan.omada_voucher_group_id) {
|
| 682 |
+
await connection.rollback();
|
| 683 |
+
return res.status(409).json({ error: 'Promo is not available yet. Please ask the attendant.' });
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
const [phoneClaimRows] = await connection.execute(
|
| 687 |
+
`SELECT p.id
|
| 688 |
+
FROM payments p
|
| 689 |
+
WHERE p.device_id = ?
|
| 690 |
+
AND p.plan_id = ?
|
| 691 |
+
AND p.phone = ?
|
| 692 |
+
AND p.status = 'completed'
|
| 693 |
+
AND p.gross_amount = 0
|
| 694 |
+
LIMIT 1`,
|
| 695 |
+
[device.id, plan.id, cleanPhone]
|
| 696 |
+
);
|
| 697 |
+
const [macClaimRows] = await connection.execute(
|
| 698 |
+
`SELECT t.id
|
| 699 |
+
FROM access_tokens t
|
| 700 |
+
JOIN payments p ON p.id = t.payment_id
|
| 701 |
+
WHERE t.device_id = ?
|
| 702 |
+
AND t.plan_id = ?
|
| 703 |
+
AND t.locked_mac = ?
|
| 704 |
+
AND p.status = 'completed'
|
| 705 |
+
AND p.gross_amount = 0
|
| 706 |
+
LIMIT 1`,
|
| 707 |
+
[device.id, plan.id, mac]
|
| 708 |
+
);
|
| 709 |
+
|
| 710 |
+
if (phoneClaimRows.length || macClaimRows.length) {
|
| 711 |
+
await connection.rollback();
|
| 712 |
+
return res.status(409).json({ error: 'This promo has already been claimed for this phone or device.' });
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
const [issuedRows] = await connection.execute(
|
| 716 |
+
`SELECT code, omada_voucher_id
|
| 717 |
+
FROM access_tokens
|
| 718 |
+
WHERE omada_voucher_group_id = ?`,
|
| 719 |
+
[plan.omada_voucher_group_id]
|
| 720 |
+
);
|
| 721 |
+
const voucher = await omada.issueVoucherFromGroup(device.omada_site_id, plan.omada_voucher_group_id, {
|
| 722 |
+
excludeCodes: issuedRows.map(row => row.code),
|
| 723 |
+
excludeVoucherIds: issuedRows.map(row => row.omada_voucher_id),
|
| 724 |
+
});
|
| 725 |
+
|
| 726 |
+
const reference = `PROMO-${uuidv4().replace(/-/g, '').slice(0, 18).toUpperCase()}`;
|
| 727 |
+
const [paymentResult] = await connection.execute(
|
| 728 |
+
`INSERT INTO payments
|
| 729 |
+
(reference, client_id, device_id, plan_id, phone, phone_provider,
|
| 730 |
+
payment_channel, provider_account_type, verification_source, counts_toward_balance,
|
| 731 |
+
confirmed_at, notes, gross_amount, snippe_fee, commission_rate,
|
| 732 |
+
commission_amount, client_credit, status, completed_at)
|
| 733 |
+
VALUES (?, ?, ?, ?, ?, 'other',
|
| 734 |
+
'tenant_direct_manual', 'platform', 'tenant_confirmed', 0,
|
| 735 |
+
NOW(), 'Captive portal free promo claim', 0.00, 0.00, ?,
|
| 736 |
+
0.00, 0.00, 'completed', NOW())`,
|
| 737 |
+
[
|
| 738 |
+
reference,
|
| 739 |
+
device.client_id,
|
| 740 |
+
device.id,
|
| 741 |
+
plan.id,
|
| 742 |
+
cleanPhone,
|
| 743 |
+
device.commission_rate,
|
| 744 |
+
]
|
| 745 |
+
);
|
| 746 |
+
|
| 747 |
+
const { tokenId, code } = await createAccessTokenForPayment({
|
| 748 |
+
connection,
|
| 749 |
+
paymentId: paymentResult.insertId,
|
| 750 |
+
clientId: device.client_id,
|
| 751 |
+
deviceId: device.id,
|
| 752 |
+
planId: plan.id,
|
| 753 |
+
durationSeconds: plan.duration_seconds,
|
| 754 |
+
downLimit: plan.down_limit,
|
| 755 |
+
downUnit: plan.down_unit,
|
| 756 |
+
upLimit: plan.up_limit,
|
| 757 |
+
upUnit: plan.up_unit,
|
| 758 |
+
code: voucher.code,
|
| 759 |
+
omadaVoucherId: voucher.voucherId,
|
| 760 |
+
omadaVoucherGroupId: plan.omada_voucher_group_id,
|
| 761 |
+
requirePreferredCode: true,
|
| 762 |
+
});
|
| 763 |
+
|
| 764 |
+
await connection.execute(
|
| 765 |
+
`UPDATE access_tokens
|
| 766 |
+
SET locked_mac = ?
|
| 767 |
+
WHERE id = ?`,
|
| 768 |
+
[mac, tokenId]
|
| 769 |
+
);
|
| 770 |
+
|
| 771 |
+
await connection.commit();
|
| 772 |
+
|
| 773 |
+
const duration = formatDuration(plan.duration_seconds);
|
| 774 |
+
const smsSent = Boolean(await sms.sendSMS(
|
| 775 |
+
cleanPhone,
|
| 776 |
+
messages.promoWifiCode(code, plan.name, duration, device.name)
|
| 777 |
+
).catch(() => false));
|
| 778 |
+
|
| 779 |
+
res.status(201).json({
|
| 780 |
+
reference,
|
| 781 |
+
token_id: tokenId,
|
| 782 |
+
code,
|
| 783 |
+
plan_name: plan.name,
|
| 784 |
+
duration_seconds: plan.duration_seconds,
|
| 785 |
+
sms_sent: smsSent,
|
| 786 |
+
message: smsSent ? 'Promo code sent successfully' : 'Promo code generated successfully',
|
| 787 |
+
});
|
| 788 |
+
} catch (err) {
|
| 789 |
+
await connection.rollback();
|
| 790 |
+
console.error('[portal/promo]', err.message);
|
| 791 |
+
res.status(500).json({ error: 'Could not issue promo code. Please ask the attendant.' });
|
| 792 |
+
} finally {
|
| 793 |
+
connection.release();
|
| 794 |
+
}
|
| 795 |
+
});
|
| 796 |
+
|
| 797 |
router.get('/:siteId/check/:reference', async (req, res) => {
|
| 798 |
try {
|
| 799 |
const payment = await db.query(
|
|
|
|
| 1029 |
return res.status(410).json({ error: 'Code has expired' });
|
| 1030 |
}
|
| 1031 |
|
| 1032 |
+
if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
|
| 1033 |
+
trace.step('token_rejected_locked_mac', { lockedMac: token.locked_mac });
|
| 1034 |
+
return res.status(403).json({ error: 'Code is locked to another device' });
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
if (token.status === 'active') {
|
| 1038 |
trace.step('active_token_validation_start');
|
| 1039 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1040 |
if (token.expires_at && new Date(token.expires_at) < new Date()) {
|
| 1041 |
await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
|
| 1042 |
trace.step('token_marked_expired');
|
src/utils/messages.js
CHANGED
|
@@ -12,6 +12,10 @@ module.exports = {
|
|
| 12 |
manualWifiCode: (code, planName, duration, deviceName, expiryPreview) =>
|
| 13 |
`WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nValid for ${duration} after first connection. If used now, access ends around ${expiryPreview}.`,
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
// Guest receives this when their WiFi token expires
|
| 16 |
tokenExpired: (deviceName, planName = null) =>
|
| 17 |
planName
|
|
|
|
| 12 |
manualWifiCode: (code, planName, duration, deviceName, expiryPreview) =>
|
| 13 |
`WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nValid for ${duration} after first connection. If used now, access ends around ${expiryPreview}.`,
|
| 14 |
|
| 15 |
+
// Guest receives this after claiming a free promo on the captive portal
|
| 16 |
+
promoWifiCode: (code, planName, duration, deviceName) =>
|
| 17 |
+
`Free WiFi promo code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nEnter this code on the WiFi login page. Valid for ${duration} after connecting.`,
|
| 18 |
+
|
| 19 |
// Guest receives this when their WiFi token expires
|
| 20 |
tokenExpired: (deviceName, planName = null) =>
|
| 21 |
planName
|
src/views/screens/portal.ejs
CHANGED
|
@@ -777,6 +777,7 @@
|
|
| 777 |
|
| 778 |
<!-- SCREEN 1: Plan selection & phone -->
|
| 779 |
<div id="screen-select" class="screen active">
|
|
|
|
| 780 |
<% if (portal.plans.length === 0) { %>
|
| 781 |
<div class="empty-state">No plans available yet.<br>Check back soon.</div>
|
| 782 |
<% } else { %>
|
|
@@ -786,16 +787,17 @@
|
|
| 786 |
const hours = Math.floor(plan.duration_seconds / 3600);
|
| 787 |
const days = Math.floor(plan.duration_seconds / 86400);
|
| 788 |
const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hr' : ' Hrs');
|
|
|
|
| 789 |
%>
|
| 790 |
<div class="plan-item">
|
| 791 |
<input type="radio" name="plan" id="plan-<%= plan.id %>"
|
| 792 |
-
data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
|
| 793 |
<label class="plan-btn" for="plan-<%= plan.id %>">
|
| 794 |
<div class="plan-row">
|
| 795 |
<span class="plan-name"><%= plan.name %></span>
|
| 796 |
-
<span class="plan-price"><%= parseInt(plan.price).toLocaleString()
|
| 797 |
</div>
|
| 798 |
-
<div class="plan-meta"><%= dur %></div>
|
| 799 |
</label>
|
| 800 |
</div>
|
| 801 |
<% }); %>
|
|
@@ -840,9 +842,9 @@
|
|
| 840 |
<% } %>
|
| 841 |
|
| 842 |
<div class="divider" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>><span>Pay via Mobile Money</span></div>
|
| 843 |
-
<p class="payment-note" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>Prefer instant activation? Enter your own number below to pay online
|
| 844 |
|
| 845 |
-
<div class="input-wrap" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>
|
| 846 |
<label class="field-label" for="phone-input">Phone Number</label>
|
| 847 |
<div class="phone-group">
|
| 848 |
<div class="phone-prefix">
|
|
@@ -856,7 +858,7 @@
|
|
| 856 |
</div>
|
| 857 |
|
| 858 |
<div class="error-msg" id="select-error"></div>
|
| 859 |
-
<button class="btn-primary" id="pay-btn" disabled <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>Select a plan to continue</button>
|
| 860 |
|
| 861 |
<% if (!showOnlinePayment) { %>
|
| 862 |
<div class="quick-access-note" style="margin-top:18px;">
|
|
|
|
| 777 |
|
| 778 |
<!-- SCREEN 1: Plan selection & phone -->
|
| 779 |
<div id="screen-select" class="screen active">
|
| 780 |
+
<% const hasPromoPlans = portal.plans.some(function(plan) { return Number(plan.price) === 0; }); %>
|
| 781 |
<% if (portal.plans.length === 0) { %>
|
| 782 |
<div class="empty-state">No plans available yet.<br>Check back soon.</div>
|
| 783 |
<% } else { %>
|
|
|
|
| 787 |
const hours = Math.floor(plan.duration_seconds / 3600);
|
| 788 |
const days = Math.floor(plan.duration_seconds / 86400);
|
| 789 |
const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hr' : ' Hrs');
|
| 790 |
+
const isPromo = Number(plan.price) === 0;
|
| 791 |
%>
|
| 792 |
<div class="plan-item">
|
| 793 |
<input type="radio" name="plan" id="plan-<%= plan.id %>"
|
| 794 |
+
data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>" data-plan-promo="<%= isPromo ? '1' : '0' %>">
|
| 795 |
<label class="plan-btn" for="plan-<%= plan.id %>">
|
| 796 |
<div class="plan-row">
|
| 797 |
<span class="plan-name"><%= plan.name %></span>
|
| 798 |
+
<span class="plan-price"><%= isPromo ? 'Free' : parseInt(plan.price).toLocaleString() + ' TZS' %></span>
|
| 799 |
</div>
|
| 800 |
+
<div class="plan-meta"><%= dur %><%= isPromo ? ' · Promo' : '' %></div>
|
| 801 |
</label>
|
| 802 |
</div>
|
| 803 |
<% }); %>
|
|
|
|
| 842 |
<% } %>
|
| 843 |
|
| 844 |
<div class="divider" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>><span>Pay via Mobile Money</span></div>
|
| 845 |
+
<p class="payment-note" <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>Prefer instant activation? Enter your own number below to pay online or claim an available free promo.</p>
|
| 846 |
|
| 847 |
+
<div class="input-wrap" <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>
|
| 848 |
<label class="field-label" for="phone-input">Phone Number</label>
|
| 849 |
<div class="phone-group">
|
| 850 |
<div class="phone-prefix">
|
|
|
|
| 858 |
</div>
|
| 859 |
|
| 860 |
<div class="error-msg" id="select-error"></div>
|
| 861 |
+
<button class="btn-primary" id="pay-btn" disabled <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>Select a plan to continue</button>
|
| 862 |
|
| 863 |
<% if (!showOnlinePayment) { %>
|
| 864 |
<div class="quick-access-note" style="margin-top:18px;">
|