# ISP360 Customer Portal Specification

**Version:** 1.0  
**Date:** 2026-06-13  
**Prepared by:** Polakis Technologies  

Companion: [DATABASE_DESIGN.md](DATABASE_DESIGN.md) | [RBAC_SPEC.md](RBAC_SPEC.md) | [PAYMENTS_SPEC.md](PAYMENTS_SPEC.md) | [CAPTIVE_PORTAL_SPEC.md](CAPTIVE_PORTAL_SPEC.md) | [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md)

---

## 1. Overview

The **Customer Portal** is the self-service web application for ISP subscribers. All pages live at the project root with the `portal_` prefix and use a dedicated **portal layout** — separate from the staff admin shell (`dashboard.php`) and the WiFi **captive portal** (`captive.php`).

Subscribers use the portal to:

- View subscriptions, usage, and account balance
- Browse and purchase packages
- Pay invoices via mobile money (STK)
- Download invoices and view payment history
- Open and reply to support tickets
- Update profile and change password
- Redeem vouchers

### Core Classes

| Class | File | Responsibility |
|-------|------|----------------|
| `PortalAuth` | `includes/portal_auth.php` | Login, session, `portalAuthRequired()` |
| `CustomerPortalService` | `includes/CustomerPortalService.php` | Business logic for portal operations |
| `PortalThemeEngine` | `includes/PortalThemeEngine.php` | Portal branding from `tenant_themes` (shared with captive) |

### Design Principles

| Principle | Implementation |
|-----------|----------------|
| Separate auth context | `portal_sessions` — not staff `sessions`, not `captive_portal_sessions` |
| Customer ID from session only | Never trust `customer_id` from URL/query on portal pages |
| Tenant from domain | `tenant_domains.domain_type = portal` |
| Mobile-first | Bottom nav on small screens; touch-friendly targets |
| Tenant branding | `tenant_themes` where `theme_type = portal` |
| Staff RBAC not used | Portal pages use `portal_auth` gate, not `permission_check()` |

### Three Portal Contexts in ISP360

| Context | User | Session | Pages |
|---------|------|---------|-------|
| **Staff admin** | ISP employees | `sessions` + `$_SESSION['user_id']` | `dashboard.php`, `customers.php`, … |
| **Customer portal** | Subscribers | `portal_sessions` + `$_SESSION['portal_customer_id']` | `portal_*.php` |
| **Captive portal** | WiFi guests | `captive_portal_sessions` | `captive.php` |

---

## 2. Architecture

```mermaid
flowchart TB
    subgraph customer [Subscriber Browser]
        Browser["Mobile / Desktop"]
    end

    subgraph portal_pages [Root Pages — portal_*]
        Login["portal_login.php"]
        Dash["portal_dashboard.php"]
        Packages["portal_packages.php"]
        Purchase["portal_purchase.php"]
        Pay["portal_pay.php"]
        Tickets["portal_tickets.php"]
        Profile["portal_profile.php"]
    end

    subgraph layouts [Portal Layout]
        PH["layouts/portal_header.php"]
        PS["layouts/portal_sidebar.php"]
        PF["layouts/portal_footer.php"]
    end

    subgraph ajax [AJAX Layer]
        PortalLogin["ajax/portal_login.php"]
        PortalPurchase["ajax/portal_purchase_package.php"]
        PortalPay["ajax/portal_initiate_payment.php"]
        PortalStk["ajax/portal_stk_push.php"]
        PortalTickets["ajax/portal_create_ticket.php"]
    end

    subgraph api [REST API]
        ApiLogin["api/v1/portal/login.php"]
        ApiSubs["api/v1/portal/subscriptions.php"]
        ApiUsage["api/v1/portal/usage.php"]
    end

    subgraph core [Core Classes]
        PA["PortalAuth"]
        CPS["CustomerPortalService"]
        PTE["PortalThemeEngine"]
        PM["PaymentManager"]
        RM["RadiusManager"]
    end

    subgraph data [Database]
        Customers["customers"]
        Users["users"]
        PortalSess["portal_sessions"]
        Subs["customer_subscriptions"]
        Inv["invoices"]
        PayTbl["payments"]
        TicketsTbl["tickets"]
        Usage["usage_aggregates_daily"]
    end

    Browser --> Login
    Login --> PortalLogin
    PortalLogin --> PA
    PA --> PortalSess
    Dash --> CPS
    Purchase --> PortalPurchase
    PortalPurchase --> PM
    PortalPay --> PM
    CPS --> Customers
    CPS --> Subs
    CPS --> Inv
    PortalPurchase --> RM
```

---

## 3. Database Tables

### 3.1 Authentication & Activity

| Table | Role |
|-------|------|
| `portal_sessions` | Active subscriber sessions (`session_token_hash`, `expires_at`) |
| `portal_activity_log` | Customer actions (login, pay, purchase, ticket) |
| `users` | Portal credentials (`user_type = customer`, linked via `customers.user_id`) |
| `customers` | Subscriber profile, balance, status |

### 3.2 Subscriptions & Billing

| Table | Role |
|-------|------|
| `customer_subscriptions` | Active/expired package subscriptions |
| `packages` | Catalog (`is_public = 1` for portal listing) |
| `package_pricing` | Currency-specific prices |
| `package_limits` | Speed caps, data limits (shown on package cards) |
| `invoices` | Outstanding and paid bills |
| `invoice_items` | Line items for invoice view |
| `payments` | Payment history |
| `payment_allocations` | Which invoices were settled |

### 3.3 Usage & Network

| Table | Role |
|-------|------|
| `usage_aggregates_daily` | Dashboard usage charts |
| `usage_aggregates_monthly` | Usage page monthly rollup |
| `pppoe_sessions` | Live session status (if PPPoE subscriber) |
| `hotspot_sessions` | Live hotspot session (if applicable) |
| `radius_accounting_summary` | Fallback usage source |

### 3.4 Support & Vouchers

| Table | Role |
|-------|------|
| `tickets` | Support tickets (`source = portal`) |
| `ticket_messages` | Thread (`sender_type = customer`) |
| `ticket_attachments` | File uploads on replies |
| `vouchers` | Redeemable codes |
| `voucher_usage` | Redemption audit |

### 3.5 Branding

| Table | Role |
|-------|------|
| `tenant_themes` | `theme_type = portal` — colors, logo, custom CSS |
| `tenant_domains` | `domain_type = portal` — e.g. `my.acmeisp.co.ke` |
| `tenant_settings` | `portal.allow_registration`, `portal.otp_login`, etc. |

### 3.6 Customer ↔ User Link

```
customers.user_id → users.user_id
users.user_type = 'customer'
users.tenant_id = customers.tenant_id
```

Portal login authenticates against `users` (password) matched by `customers.phone` or `customers.email`.

---

## 4. Authentication: `PortalAuth`

**File:** `includes/portal_auth.php`

Separate from staff `includes/auth.php`. Never mix session keys.

### 4.1 Session Keys

```php
$_SESSION['portal_customer_id']   // int — canonical identity
$_SESSION['portal_tenant_id']      // int
$_SESSION['portal_user_id']        // int — users.user_id
$_SESSION['portal_display_name']   // string
$_SESSION['portal_token']          // raw token (cookie); DB stores hash
```

### 4.2 Core Functions

```php
/**
 * True if valid portal session exists and not expired.
 */
function portalLoggedIn(): bool;

/**
 * Return portal customer_id or null.
 */
function portalCustomerId(): ?int;

/**
 * Return portal tenant_id or null.
 */
function portalTenantId(): ?int;

/**
 * Redirect to portal_login.php if not authenticated.
 */
function portalAuthRequired(): void;

/**
 * Resolve tenant from portal domain (tenant_domains.domain_type = portal).
 */
function portalTenantFromDomain(): ?int;

/**
 * Create portal session after successful login.
 */
function portalCreateSession(PDO $pdo, array $customer, array $user): void;

/**
 * Destroy portal session and cookie.
 */
function portalDestroySession(PDO $pdo): void;

/**
 * Log activity to portal_activity_log.
 */
function portalLogActivity(
    PDO $pdo,
    int $tenant_id,
    int $customer_id,
    string $activity_type,
    ?string $description = null,
    ?array $metadata = null
): void;
```

### 4.3 Login Methods

| Mode | Identifier | Validation |
|------|------------|------------|
| **Password** (default) | Phone or email + password | `password_verify()` on `users.password_hash` |
| **OTP** (optional) | Phone + SMS code | Tenant setting `portal.otp_login = 1`; code in `notification_queue` |

**Login query (password mode):**

```sql
SELECT c.*, u.user_id, u.password_hash, u.status AS user_status
FROM customers c
INNER JOIN users u ON u.user_id = c.user_id AND u.tenant_id = c.tenant_id
WHERE c.tenant_id = ?
  AND (c.phone = ? OR c.email = ?)
  AND c.deleted_at IS NULL
  AND c.status IN ('active', 'pending')
LIMIT 1
```

### 4.4 Brute-Force Policy

| Rule | Value |
|------|-------|
| Max failed attempts | 5 per phone per 15 minutes |
| Lockout | Use `users.failed_login_count` + `users.locked_until` |
| Log failures | `portal_activity_log` activity_type `login_failed` |
| Session lifetime | 7 days (configurable `portal.session_lifetime`) |
| Remember me | Extends to 30 days |

### 4.5 Login Flow

```mermaid
sequenceDiagram
    participant C as Customer
    participant PL as portal_login.php
    participant AJAX as ajax/portal_login.php
    participant PA as PortalAuth
    participant DB as MySQL

    C->>PL: Enter phone + password
    PL->>AJAX: POST credentials
    AJAX->>DB: Resolve customer + user
    alt Invalid
        AJAX-->>PL: JSON error
    else Suspended
        AJAX-->>PL: Account suspended message
    else Success
        AJAX->>PA: portalCreateSession()
        AJAX->>DB: INSERT portal_sessions
        AJAX->>DB: INSERT portal_activity_log
        AJAX-->>C: Redirect portal_dashboard.php
    end
```

### 4.6 Self-Registration

When `tenant_settings.portal.allow_registration = 1`:

1. `portal_register.php` collects name, phone, email, password
2. `ajax/portal_register.php` creates `customers` (status=pending) + `users` (user_type=customer)
3. Assigns `customer` role via `user_roles`
4. Optional: SMS verification before activation
5. Staff may need to approve (`customers.status` pending → active)

---

## 5. Class: `CustomerPortalService`

**File:** `includes/CustomerPortalService.php`

High-level portal operations. All methods require `tenant_id` + `customer_id` from portal session.

### 5.1 Dashboard

```php
public function getDashboardSummary(int $tenant_id, int $customer_id): array;
```

Returns:

| Key | Source |
|-----|--------|
| `active_subscriptions` | `customer_subscriptions` WHERE status=active |
| `balance_due` | SUM `invoices.balance_due` WHERE status IN (issued, partial, overdue) |
| `next_expiry` | MIN `expires_at` from active subscriptions |
| `usage_today_mb` | `usage_aggregates_daily` for today |
| `data_cap_percent` | package_limits vs usage |
| `open_tickets` | tickets WHERE customer_id AND status not closed |

### 5.2 Packages

```php
/** Public packages for portal catalog. */
public function listPublicPackages(int $tenant_id): array;

/** Check customer eligibility (status, existing subscription rules). */
public function canPurchasePackage(int $tenant_id, int $customer_id, int $package_id): bool;
```

Filters: `packages.is_public = 1`, `packages.is_active = 1`, `deleted_at IS NULL`.

### 5.3 Purchase Package

```php
/**
 * Create subscription + invoice; optionally initiate STK.
 * Returns: subscription_id, invoice_id, payment_id (if STK)
 */
public function purchasePackage(
    int $tenant_id,
    int $customer_id,
    int $package_id,
    string $payment_method,  // mpesa|momo|cash_later
    ?string $phone = null
): array;
```

Flow:

1. Validate `canPurchasePackage()`
2. INSERT `customer_subscriptions` (status=pending until paid)
3. Generate invoice via billing module
4. If mobile money → `PaymentManager::initiateCollection()` with `metadata.invoice_id`
5. On payment complete (callback) → activate subscription, `RadiusManager` provision PPPoE/hotspot
6. `portalLogActivity('package_purchased')`

### 5.4 Pay Invoice

```php
public function payInvoice(
    int $tenant_id,
    int $customer_id,
    int $invoice_id,
    string $phone
): array;
```

Validates invoice belongs to customer; initiates STK for `balance_due`; auto-allocates on callback via `sp_allocate_payment`.

### 5.5 Usage

```php
public function getUsageChart(
    int $tenant_id,
    int $customer_id,
    string $period  // 7d|30d|month
): array;

public function getLiveSession(int $tenant_id, int $customer_id): ?array;
```

Sources: `usage_aggregates_daily`, `pppoe_sessions`, `vw_active_pppoe_sessions`.

### 5.6 Tickets

```php
public function listTickets(int $tenant_id, int $customer_id, array $filters = []): array;

public function createTicket(
    int $tenant_id,
    int $customer_id,
    string $subject,
    string $description,
    ?int $category_id = null
): int;

public function replyToTicket(
    int $tenant_id,
    int $customer_id,
    int $ticket_id,
    string $message
): void;
```

Tickets created with `source = portal`; messages with `sender_type = customer`, `is_internal = 0`.

### 5.7 Voucher Redeem

```php
public function redeemVoucher(
    int $tenant_id,
    int $customer_id,
    string $voucher_code
): array;
```

Calls `sp_redeem_voucher`; links voucher to customer subscription or extends existing package.

### 5.8 Profile

```php
public function getProfile(int $tenant_id, int $customer_id): array;

public function updateProfile(int $tenant_id, int $customer_id, array $data): void;

public function changePassword(int $tenant_id, int $customer_id, string $current, string $new): void;
```

Editable: `email`, `phone_alt`, addresses — not `customer_number` or `balance`.

---

## 6. Portal Theme & Layout

### 6.1 Theme Engine

Reuse `PortalThemeEngine` from [CAPTIVE_PORTAL_SPEC.md](CAPTIVE_PORTAL_SPEC.md) with `theme_type = portal`:

```php
$theme = (new PortalThemeEngine($pdo))->resolveTheme($tenant_id, null, 'portal');
```

Default portal palette (overridden by tenant):

| Token | Default |
|-------|---------|
| `primary_color` | `#2563eb` |
| `secondary_color` | `#4f46e5` |
| `background_color` | `#f8fafc` |

### 6.2 Layout Files

| File | Role |
|------|------|
| `layouts/portal-start.php` | Opens HTML, loads theme, header + sidebar |
| `layouts/portal-end.php` | Footer, scripts, closes document |
| `layouts/portal_header.php` | Logo, tenant name, user menu, balance badge |
| `layouts/portal_sidebar.php` | Nav links (desktop) |
| `layouts/portal_footer.php` | Copyright, support phone |
| `assets/css/portal-theme.css` | Subscriber portal design system |

### 6.3 Page Bootstrap Template

Same structural pattern as admin shell ([UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md)) but portal layout:

```php
<?php
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/portal_auth.php';

portalAuthRequired();

$tenant_id = portalTenantId();
$customer_id = portalCustomerId();
$page_title = 'My Dashboard';

$portal = new CustomerPortalService($pdo);
$summary = $portal->getDashboardSummary($tenant_id, $customer_id);

include __DIR__ . '/layouts/portal-start.php';
?>

<!-- Page content -->

<?php include __DIR__ . '/layouts/portal-end.php'; ?>
```

### 6.4 Navigation Structure

| Label | URL | Icon |
|-------|-----|------|
| Home | `portal_dashboard.php` | `ri-home-4-line` |
| Packages | `portal_packages.php` | `ri-shopping-bag-line` |
| Invoices | `portal_invoices.php` | `ri-file-list-3-line` |
| Pay | `portal_pay.php` | `ri-wallet-3-line` |
| Usage | `portal_usage.php` | `ri-bar-chart-line` |
| Tickets | `portal_tickets.php` | `ri-customer-service-2-line` |
| Profile | `portal_profile.php` | `ri-user-line` |

Mobile: bottom tab bar (4 primary + More menu).

### 6.5 Public Pages (No `portalAuthRequired`)

| Page | Behavior |
|------|----------|
| `portal_login.php` | Redirect to dashboard if `portalLoggedIn()` |
| `portal_register.php` | Redirect if logged in; check `allow_registration` setting |
| `portal_logout.php` | Destroy session; show confirmation (like staff `logout.php` pattern) |

---

## 7. Portal Pages Reference

All root-level `portal_*.php` files per [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).

| Page | Auth | Purpose | Key AJAX |
|------|------|---------|----------|
| `portal_login.php` | public | Login form | `ajax/portal_login.php` |
| `portal_register.php` | public | Self-registration | `ajax/portal_register.php` |
| `portal_logout.php` | portal | End session | — |
| `portal_dashboard.php` | portal | Home, KPIs, usage chart | `ajax/portal_dashboard_data.php` |
| `portal_packages.php` | portal | Browse packages | `ajax/portal_get_packages.php` |
| `portal_purchase.php` | portal | Checkout + STK | `ajax/portal_purchase_package.php`, `ajax/portal_stk_push.php` |
| `portal_invoices.php` | portal | Invoice list | `ajax/portal_get_invoices.php` |
| `portal_invoice_view.php` | portal | Invoice detail + PDF | `ajax/portal_download_invoice.php` |
| `portal_pay.php` | portal | Pay outstanding balance | `ajax/portal_initiate_payment.php` |
| `portal_payments.php` | portal | Payment history | `ajax/portal_get_payments.php` |
| `portal_usage.php` | portal | Usage charts + live session | `ajax/portal_usage_data.php` |
| `portal_tickets.php` | portal | Ticket list + create | `ajax/portal_get_tickets.php`, `ajax/portal_create_ticket.php` |
| `portal_ticket_view.php` | portal | Thread + reply | `ajax/portal_reply_ticket.php` |
| `portal_profile.php` | portal | Edit profile | `ajax/portal_save_profile.php` |
| `portal_password.php` | portal | Change password | `ajax/portal_change_password.php` |
| `portal_voucher.php` | portal | Redeem voucher | `ajax/portal_redeem_voucher.php` |

### 7.1 Dashboard Widgets

| Widget | Data |
|--------|------|
| Subscription card | Package name, expiry countdown, renew CTA |
| Balance due | Outstanding invoice total → link `portal_pay.php` |
| Usage donut | Today/month vs data cap |
| Quick actions | Pay, Buy package, New ticket |
| Recent payments | Last 3 from `payments` |

### 7.2 Purchase Page Flow

1. Select package from `portal_packages.php`
2. `portal_purchase.php?package_id=X` — summary, setup fee, total
3. Enter M-Pesa phone (default: customer phone)
4. AJAX STK push → poll `ajax/check_mpesa_status.php`
5. Success → redirect dashboard with flash "Package activated"

---

## 8. Workflows

### 8.1 Package Purchase with M-Pesa

```mermaid
sequenceDiagram
    participant C as Customer
    participant PP as portal_purchase.php
    participant AJAX as portal_purchase_package.php
    participant CPS as CustomerPortalService
    participant PM as PaymentManager
    participant CB as payment callback
    participant RM as RadiusManager

    C->>PP: Confirm package
    PP->>AJAX: POST package_id, phone
    AJAX->>CPS: purchasePackage()
    CPS->>CPS: Create subscription pending + invoice
    CPS->>PM: initiateCollection()
    PM-->>C: STK prompt
    CB->>PM: completePayment()
    PM->>CPS: activate subscription
    CPS->>RM: provision PPPoE/hotspot user
    CPS-->>C: Success redirect
```

### 8.2 Pay Outstanding Invoice

```mermaid
sequenceDiagram
    participant C as Customer
    participant Pay as portal_pay.php
    participant AJAX as portal_initiate_payment.php
    participant CPS as CustomerPortalService
    participant PM as PaymentManager

    C->>Pay: Select invoice(s) or pay all
    Pay->>AJAX: invoice_id + phone
    AJAX->>CPS: payInvoice()
    CPS->>PM: STK for balance_due
    PM-->>C: Payment complete
    Note over PM: sp_allocate_payment on callback
```

### 8.3 Support Ticket

```mermaid
sequenceDiagram
    participant C as Customer
    participant T as portal_tickets.php
    participant AJAX as portal_create_ticket.php
    participant CPS as CustomerPortalService
    participant N as NotificationService

    C->>T: New ticket form
    T->>AJAX: subject + description
    AJAX->>CPS: createTicket()
    CPS->>N: Notify assigned support queue
    AJAX-->>C: Redirect portal_ticket_view.php
```

### 8.4 Password Reset (Forgot Password)

1. Link on `portal_login.php` → `portal_forgot_password.php` (handler page)
2. `ajax/portal_forgot_password.php` — send reset link SMS/email
3. Token stored hashed in `users` or dedicated reset table
4. `portal_reset_password.php` — set new password
5. `ajax/portal_reset_password.php` — validate token, update hash

---

## 9. AJAX Endpoints

All portal AJAX endpoints:

1. `require_once includes/portal_auth.php` (except login/register/forgot)
2. Validate `portalTenantId()` + `portalCustomerId()` match request scope
3. Return JSON `{success: bool, data?: ..., error?: string}`

| Endpoint | Auth | Action |
|----------|------|--------|
| `ajax/portal_login.php` | public | Authenticate, create session |
| `ajax/portal_register.php` | public | Self-register customer |
| `ajax/portal_forgot_password.php` | public | Send reset token |
| `ajax/portal_reset_password.php` | public | Reset with token |
| `ajax/portal_dashboard_data.php` | portal | Dashboard JSON |
| `ajax/portal_get_packages.php` | portal | Package catalog |
| `ajax/portal_purchase_package.php` | portal | Start purchase |
| `ajax/portal_stk_push.php` | portal | STK push (alias) |
| `ajax/portal_get_invoices.php` | portal | Invoice list |
| `ajax/portal_download_invoice.php` | portal | PDF download |
| `ajax/portal_initiate_payment.php` | portal | Pay invoice STK |
| `ajax/portal_get_payments.php` | portal | Payment history |
| `ajax/portal_usage_data.php` | portal | Usage chart data |
| `ajax/portal_get_tickets.php` | portal | Ticket list |
| `ajax/portal_create_ticket.php` | portal | New ticket |
| `ajax/portal_reply_ticket.php` | portal | Add message |
| `ajax/portal_save_profile.php` | portal | Update profile |
| `ajax/portal_change_password.php` | portal | Change password |
| `ajax/portal_redeem_voucher.php` | portal | Redeem voucher |

**IDOR prevention:** Every query includes `AND customer_id = portalCustomerId()` — never from `$_GET['customer_id']`.

---

## 10. REST API (`api/v1/portal/`)

For mobile apps and third-party integrations.

| Endpoint | Auth | Purpose |
|----------|------|---------|
| `api/v1/portal/login.php` | public | JSON login → API token or session cookie |
| `api/v1/portal/subscriptions.php` | Bearer / session | List subscriptions |
| `api/v1/portal/usage.php` | Bearer / session | Usage aggregates |

API auth may use `api_tokens` with customer-scoped permissions or portal session cookie for same-origin apps.

---

## 11. Tenant Configuration

Stored in `tenant_settings` (`setting_group = portal`):

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `portal.allow_registration` | bool | 0 | Enable `portal_register.php` |
| `portal.otp_login` | bool | 0 | SMS OTP instead of password |
| `portal.session_lifetime` | int | 604800 | Seconds (7 days) |
| `portal.show_usage` | bool | 1 | Usage page enabled |
| `portal.show_voucher_redeem` | bool | 1 | Voucher page in nav |
| `portal.default_gateway` | string | mpesa_ke | STK gateway code |
| `portal.support_phone` | string | — | Shown in footer |
| `portal.terms_url` | string | — | Terms link on register |

---

## 12. RBAC & Access Control

Portal pages use **`portal_auth`** — not staff permissions.

| Staff Permission | Portal Relevance |
|----------------|------------------|
| `portal.login` | Customer role — portal access |
| `portal.register` | Self-registration allowed |
| `portal.purchase` | Buy packages |
| `portal.pay` | Make payments |
| `portal.tickets` | Support tickets |
| `portal.profile` | Edit profile |

**Customer role** (`role_code = customer`) receives all `portal.*` permissions. Staff users must not access `portal_*` pages while staff session is active — redirect to `dashboard.php`.

### 12.1 Dual Session Guard

```php
// Top of every portal_*.php (except public)
if (isLoggedIn()) {
    // Staff session active — prevent confusion
    redirect('dashboard.php');
}
portalAuthRequired();
```

---

## 13. Security

| Risk | Mitigation |
|------|------------|
| IDOR on invoices/tickets | `customer_id` from session only |
| Cross-tenant access | `tenant_id` from domain + session match |
| Session fixation | Regenerate session ID on login |
| XSS | Escape output; CSP on portal layout |
| CSRF | Token on all portal AJAX POSTs |
| Payment amount tampering | Server reads invoice balance; ignore client amount |
| Suspended customer | Block login if `customers.status = suspended` |

### 13.1 Account States

| `customers.status` | Portal access |
|--------------------|---------------|
| `active` | Full access |
| `pending` | Login allowed; limited until staff activates |
| `suspended` | Login blocked — "Contact support" |
| `terminated` | Login blocked |
| `inactive` | Login blocked |

---

## 14. Notifications

| Event | Channel | Template |
|-------|---------|----------|
| Login from new device | SMS | `portal_login_alert` |
| Payment received | SMS + email | `payment_received` |
| Package activated | SMS | `subscription_activated` |
| Invoice issued | email | `invoice_issued` |
| Ticket reply (staff) | SMS + email | `ticket_reply` |
| Subscription expiring | SMS | `subscription_expiry_reminder` |

Triggered from `CustomerPortalService` via `NotificationService`.

---

## 15. Cron Integration

| Script | Relevance |
|--------|-----------|
| `cron/cleanup_sessions.php` | Purge expired `portal_sessions` |
| `cron/subscription_renewal.php` | Auto-renew; portal shows renewal status |
| `cron/dunning_process.php` | Overdue reminders → portal pay CTA in SMS |
| `cron/notification_queue.php` | OTP, payment receipts, ticket updates |

---

## 16. Error Handling

| Error | User Message |
|-------|--------------|
| Session expired | Redirect login — "Please sign in again" |
| Invoice not found | "Invoice not available" |
| Package not available | "This package is no longer offered" |
| Payment failed | "Payment could not be completed. Try again." |
| Voucher invalid | "Invalid or expired voucher code" |
| Ticket not found | "Support ticket not found" |

---

## 17. Design Language

Portal uses **subscriber-friendly** styling — distinct from admin ISP360 blue shell:

- Softer cards, larger typography, generous padding
- Balance and expiry shown prominently
- Package cards with price + "Buy now" CTA
- Chart.js for usage (same library as admin dashboard)
- Tenant logo in header from `tenant_themes.logo_url`

CSS file: `assets/css/portal-theme.css` — variables aligned with `PortalThemeEngine` output.

---

## 18. Implementation Checklist (Wave 2)

- [ ] `includes/portal_auth.php`
- [ ] `includes/CustomerPortalService.php`
- [ ] `layouts/portal-start.php`, `portal-end.php`, `portal_header.php`, `portal_sidebar.php`, `portal_footer.php`
- [ ] `assets/css/portal-theme.css`
- [ ] 16 `portal_*.php` root pages
- [ ] 18 `ajax/portal_*.php` endpoints
- [ ] `api/v1/portal/login.php`, `subscriptions.php`, `usage.php`
- [ ] Forgot/reset password flow (`portal_forgot_password` handlers)
- [ ] Integration: `PaymentManager`, `RadiusManager`, `NotificationService`
- [ ] Tenant settings UI for portal config (in `settings.php` or dedicated section)
- [ ] Mobile bottom navigation component
- [ ] E2E: login → purchase → STK → subscription active

---

## 19. Related Documents

| Document | Relevance |
|----------|-----------|
| [PAYMENTS_SPEC.md](PAYMENTS_SPEC.md) | STK push, allocation, callbacks |
| [CAPTIVE_PORTAL_SPEC.md](CAPTIVE_PORTAL_SPEC.md) | WiFi guest portal (separate) |
| [RADIUS_SPEC.md](RADIUS_SPEC.md) | Post-purchase provisioning |
| [UI_SPECIFICATIONS.md](UI_SPECIFICATIONS.md) | Per-page field specs |
| [RBAC_SPEC.md](RBAC_SPEC.md) | `portal.*` permissions, Customer role |
| [UI_SHELL_PATTERN.md](UI_SHELL_PATTERN.md) | Page bootstrap pattern |
| [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) | File manifest |
