Add tenant direct payment token sales
Browse files- API_DOCUMENTATION.md +169 -14
- schema.sql +14 -2
- src/app.js +1 -0
- src/routes/auth.routes.js +155 -1
- src/routes/manualSales.routes.js +188 -0
- src/routes/portal.routes.js +11 -1
- src/routes/sessions.routes.js +3 -0
- src/utils/migrate.js +19 -1
- src/utils/tokenSales.js +62 -0
- src/views/screens/portal.ejs +206 -0
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 —
|
| 17 |
-
7. [Tenant —
|
| 18 |
-
8. [
|
| 19 |
-
9. [
|
| 20 |
-
10. [
|
| 21 |
-
11. [
|
| 22 |
-
12. [
|
|
|
|
| 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 —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
##
|
| 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 |
-
##
|
| 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 |
-
##
|
| 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 |
-
##
|
| 1285 |
|
| 1286 |
All errors follow this shape:
|
| 1287 |
|
|
@@ -1303,7 +1456,7 @@ All errors follow this shape:
|
|
| 1303 |
|
| 1304 |
---
|
| 1305 |
|
| 1306 |
-
##
|
| 1307 |
|
| 1308 |
### Device `status`
|
| 1309 |
| Value | Meaning |
|
|
@@ -1354,7 +1507,7 @@ All errors follow this shape:
|
|
| 1354 |
|
| 1355 |
---
|
| 1356 |
|
| 1357 |
-
##
|
| 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)
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|