# Frontend API Guide — Super-Admin SaaS Surface

This document describes the **platform / super-admin API** for The Wealth Manager (TWM): packages,
subscriptions, clients (plan embed), invoices, coupons, dashboard insights, revenue analytics, audit
log, permissions/roles, admin management, and push notifications.

Every endpoint here is **live and tested**. Request bodies, query params, and response JSON are the
**actual shapes** the backend returns (copied from real seeded data) — build straight against them.

> **Not covered here:** the existing client-facing wealth endpoints (auth, assets, transactions,
> saving-goals, instalments, bank-statements). Those are documented in `BACKEND_README.md`.
> **Disabled right now:** Platform Settings and Email Templates routes are commented out in
> `routes/api.php` — they exist in code but are not reachable, so they are intentionally omitted.

---

## Table of contents

- [0. Quick start](#0-quick-start)
- [1. Access model — roles & permissions](#1-access-model--roles--permissions)
- [2. Modules](#2-modules)
  - [2.1 Packages](#21-packages)
  - [2.2 Subscriptions](#22-subscriptions)
  - [2.3 Clients (plan embed)](#23-clients-plan-embed)
  - [2.4 Invoices](#24-invoices)
  - [2.5 Coupons](#25-coupons)
  - [2.6 Dashboard insights](#26-dashboard-insights)
  - [2.7 Revenue analytics](#27-revenue-analytics)
  - [2.8 Audit log](#28-audit-log)
  - [2.9 Permissions & roles matrix](#29-permissions--roles-matrix)
  - [2.10 Manage admins](#210-manage-admins)
  - [2.11 Push notifications](#211-push-notifications)
- [3. Appendices](#3-appendices)
  - [Enums & constants](#enums--constants)
  - [Error responses](#error-responses)

---

# 0. Quick start

### Base URL & versioning
All routes are prefixed with `/api/v1`. Example: `GET https://be-wealth.experience-saudi.com/api/v1/packages`.

### Authentication
Send the Sanctum token in the **`X-Authorization`** header (not the standard `Authorization`):

```
X-Authorization: Bearer <token>
Accept: application/json
```

Get the token from `POST /api/v1/auth/login` (see `BACKEND_README.md`). A `401` on any protected route
means the token is missing/expired — clear auth and redirect to login.

### The response envelope
**Every** JSON response has this exact shape:

```json
{
  "status": 200,
  "message": "Human readable message",
  "data": { },
  "errors": []
}
```

- `status` = the HTTP status code (integer).
- `data` = the payload (object, array, or paginated block — see below).
- `errors` = `[]` on success; a field→messages map on validation failure (422).

> **List endpoints with a summary add one more top-level key, `stats`** (sibling of `data`, **not**
> inside it). See the per-endpoint docs — this matters for your axios interceptor (if it auto-returns
> `response.data.data`, read the summary from `response.data.stats`, not from inside `data`).

### Two list shapes (important)
There are **two different list shapes** in this API. Each endpoint says which one it uses.

**A) Paginated + `stats`** (subscriptions, invoices, coupons, audit):
```json
{
  "status": 200,
  "message": "...",
  "data": {
    "meta": { "current_page": 1, "per_page": 10, "total": 5, "last_page": 1,
              "first_page_url": "...?page=1", "last_page_url": "...?page=1",
              "next_page_url": null, "prev_page_url": null },
    "items": [ /* resource objects */ ]
  },
  "stats": { /* the summary numbers */ },
  "errors": []
}
```

**B) Flat list + summary inside `data`** (packages only — **not paginated**):
```json
{
  "status": 200,
  "message": "...",
  "data": {
    "summary": { /* summary numbers */ },
    "packages": [ /* all packages, no pagination */ ]
  },
  "errors": []
}
```

### Query conventions (paginated lists)
- `?page=<n>` — 1-based page number.
- `?per_page=<n>` — page size (default **10**).
- `?search=<term>` — full-text-ish search (columns differ per endpoint, documented below).
- Filters — per-endpoint query params (e.g. `?status=active`).
- `?export=csv` or `?export=xlsx` — returns a **binary file download** (Blob), not JSON. Available on
  packages, subscriptions, invoices, coupons, audit. Leave un-unwrapped in your HTTP client.

### Write conventions
- **Create** = `POST /<resource>` (JSON, or `multipart/form-data` when files are involved).
- **Update** = the route is declared as `PUT`/`PATCH`. With `multipart/form-data`, send a `POST` with a
  `_method=PUT` field (Laravel method spoofing). JSON `PUT` works directly.
- **Toggle / state change** = `PATCH` with a small JSON body.

### Acting on behalf of a client (impersonation)
Send `X-Tenant-Id: <client_id>` to scope data to that client. Requires the `clients.impersonate`
permission (or agency/super-admin). Used by the client-facing endpoints, not the platform ones here.

---

# 1. Access model — roles & permissions

Two layers:
- **Roles** (Spatie): `role.super_admin`, `role.admin`, `role.agency`, `role.client`, plus the package
  roles `package.track_analyze` / `package.wealth_manager` / `package.agency`.
- **Permissions** (14 keys) assigned **directly** to each admin. The super admin holds all 14, so it
  passes every gate. A delegated admin only passes the gates whose permission it holds — otherwise it
  gets **`403`**.

### Permission → endpoint gate

| Permission | Gates |
|---|---|
| `dashboard.view` | `GET /admin/insights` |
| `revenue.view` | `GET /admin/revenue` |
| `packages.manage` | all `/packages*` |
| `subscriptions.manage` | all `/subscriptions*` |
| `invoices.manage` | all `/invoices*` |
| `coupons.manage` | all `/coupons*` |
| `clients.view` | `GET /clients`, `GET /clients/{id}` |
| `clients.manage` | `POST/PUT/DELETE /clients` |
| `clients.impersonate` | `X-Tenant-Id` "view as client" |
| `push.send` | all `/push-notifications*` |
| `support.handle` | *(reserved — Support module not in this release)* |
| `admins.manage` | all `/admins*`, `/admin/permissions`, `/admin/roles` |
| `settings.manage` | *(Settings/Email Templates — routes disabled)* |
| `activity.view` | `/activity-logs*`, `GET /admin/audit` |

`/clients*` also accepts agency/super-admin **by role** (they don't need the client permissions).

---

# 2. Modules

Legend for request tables: **req** = required, **opt** = optional/sometimes, **null** = nullable.

---

## 2.1 Packages

The plan catalogue. Each package has a `tier` bound to a Spatie package role; subscribing assigns it.

Permission: **`packages.manage`**. List shape: **B (flat + summary, not paginated)**.

### `GET /api/v1/packages`
Query: `?tier=WEALTH_MANAGER` · `?is_active=true|false` · `?search=<name|description>` · `?export=csv|xlsx`.

**Response 200:**
```json
{
  "status": 200,
  "message": "Packages retrieved successfully",
  "data": {
    "summary": {
      "total_packages": 3,
      "active_packages": 3,
      "total_subscribers": 4,
      "mrr": 496,
      "currency_code": "AED"
    },
    "packages": [
      {
        "id": 2,
        "name": "Wealth Manager",
        "tier": "WEALTH_MANAGER",
        "description": "For serious portfolios.",
        "price_monthly": 49,
        "price_yearly": 490,
        "currency_code": "AED",
        "features": ["Unlimited assets", "AI extraction", "Priority support"],
        "is_active": true,
        "is_featured": true,
        "subscribers_count": 4
      }
    ]
  },
  "errors": []
}
```
- `subscribers_count` = count of **active** subscriptions on this package (read-only).
- `summary.mrr` = Σ(`price_monthly` × `subscribers_count`).

### `GET /api/v1/packages/tires`
Returns the tier enum values for dropdowns. (Note the spelling `/tires` — that is the real path.)
```json
{ "status": 200, "message": "Package tiers retrieved successfully",
  "data": { "tiers": ["TRACK_ANALYZE", "WEALTH_MANAGER", "WHITE_LABEL"] }, "errors": [] }
```

### `GET /api/v1/packages/{id}`
Returns one package (same object shape as a list item). `404` if not found.

### `POST /api/v1/packages`
**Request body (JSON):**
```json
{
  "name": "Wealth Manager",
  "tier": "WEALTH_MANAGER",
  "description": "For serious portfolios.",
  "price_monthly": 49,
  "price_yearly": 490,
  "currency_code": "AED",
  "features": ["Unlimited assets", "AI extraction", "Priority support"],
  "limits": { "max_assets": null, "max_clients": null },
  "is_active": true,
  "is_featured": true
}
```

| field | type | rule |
|---|---|---|
| `name` | string | req, max 255 |
| `tier` | enum | req — `TRACK_ANALYZE` / `WEALTH_MANAGER` / `WHITE_LABEL` |
| `description` | string | null, max 2000 |
| `price_monthly` | number | req, ≥ 0 |
| `price_yearly` | number | req, ≥ 0 |
| `currency_code` | string | null — must exist in `currencies.code` (e.g. `AED`) |
| `features` | string[] | null |
| `limits.max_assets` | int | null, ≥ 0 (`null` = unlimited) |
| `limits.max_clients` | int | null, ≥ 0 |
| `is_active` | bool | opt (default true) |
| `is_featured` | bool | opt (default false) |

> `limits.max_assets` / `limits.max_clients` are **stored** but the current response object does **not**
> echo them back. Persisted for later use.

**Response 201:** `{ "data": { …the created package… } }` (single object).

### `PUT /api/v1/packages/{id}`
Same fields, all **optional** (`sometimes`). `404` if not found. Returns the updated package.

### `DELETE /api/v1/packages/{id}`
- `200` `Package deleted successfully` — only if the package has **zero** subscriptions.
- `409` `This package has subscriptions and cannot be deleted. Archive it (is_active=false) instead.`
- `404` if not found.

---

## 2.2 Subscriptions

One row per client subscription. `amount`/`mrr` are derived from the package + interval — never sent by
the client. Managing a subscription also syncs the client's package role.

Permission: **`subscriptions.manage`**. List shape: **A (paginated + stats)**.

### `GET /api/v1/subscriptions`
Query: `?status=active|trialing|past_due|cancelled` · `?client_id=<id>` · `?package_id=<id>` ·
`?search=<client name|email>` · `?per_page=` · `?page=` · `?export=csv|xlsx`.

**Response 200:**
```json
{
  "status": 200,
  "message": "Subscriptions retrieved successfully",
  "data": {
    "meta": {
      "current_page": 1, "per_page": 10, "total": 5, "last_page": 1,
      "first_page_url": "http://.../subscriptions?page=1",
      "last_page_url": "http://.../subscriptions?page=1",
      "next_page_url": null, "prev_page_url": null
    },
    "items": [
      {
        "id": 2,
        "client_id": 2,
        "client_name": "Client User 1",
        "client_email": "client1@example.com",
        "package_id": 2,
        "package_name": "Wealth Manager",
        "tier": "WEALTH_MANAGER",
        "status": "active",
        "billing_interval": "monthly",
        "amount": 49,
        "mrr": 49,
        "currency_code": "AED",
        "current_period_start": "2026-06-07",
        "current_period_end": "2026-07-07",
        "trial_ends_at": null,
        "created_at": "2026-06-07",
        "cancelled_at": null
      }
    ]
  },
  "stats": {
    "active": 4, "trialing": 3, "past_due": 0, "cancelled": 0,
    "mrr": 454.66, "currency_code": "AED"
  },
  "errors": []
}
```
- `mrr` = `amount` if monthly, `amount/12` if yearly.
- `stats.mrr` counts **active** subscriptions only.

### `POST /api/v1/subscriptions`
**Request body (JSON):**
```json
{ "client_id": 2, "package_id": 2, "billing_interval": "monthly", "status": "active", "trial_ends_at": null }
```

| field | type | rule |
|---|---|---|
| `client_id` | int | req — exists in `users` |
| `package_id` | int | req — exists in `packages` |
| `billing_interval` | enum | opt — `monthly` / `yearly` (default `monthly`) |
| `status` | enum | opt — `trialing` / `active` / `past_due` / `cancelled` (default `trialing`) |
| `trial_ends_at` | date | null |

`amount` is computed from the package + interval. **Response 201** = the created subscription object.

### `PUT /api/v1/subscriptions/{id}` — manage (change plan / interval / status)
**Request body (JSON, all optional):**
```json
{ "package_id": 3, "billing_interval": "yearly", "status": "active" }
```
- Re-pointing the plan or interval **recomputes** `amount`/`mrr`.
- `status: "cancelled"` sets `cancelled_at` (today) and **removes** the client's package role; any other
  status clears `cancelled_at` and (re)assigns the role.
- Emits a `plan_change` audit entry.
- `404` if not found. **Response 200** = updated subscription object.

---

## 2.3 Clients (plan embed)

The clients endpoints already existed; the **new** part is the embedded `subscription` summary and the
optional `package_id`/`billing_interval` on create. Full client docs live in `BACKEND_README.md`.

Permission: **`clients.view`** (read) / **`clients.manage`** (write) — agency & super-admin pass by role.
List shape: standard paginated `{ meta, items }` (no `stats`).

### Client object (list & show)
```json
{
  "id": 2,
  "name": "Client User 1",
  "email": "client1@example.com",
  "phone": null,
  "image_url": null,
  "preferred_currency": { "id": 1, "code": "USD" },
  "subscription": {
    "package_id": 2,
    "package_name": "Wealth Manager",
    "tier": "WEALTH_MANAGER",
    "status": "active",
    "trial_ends_at": null
  },
  "created_at": "2026-05-05T15:20:59.000000Z"
}
```
- `subscription` is `null` when the client has no subscription.

### `POST /api/v1/clients` (multipart/form-data)
Existing fields **plus**:

| field | type | rule |
|---|---|---|
| `name` | string | req |
| `email` | string | req — unique in `users` |
| `preferred_currency_id` | int | req — exists in `currencies` |
| `phone` | string | null |
| `image` | file | opt — image, ≤ 2 MB |
| `package_id` | int | null — exists in `packages`; when present, assigns the package role **and** starts a subscription |
| `billing_interval` | enum | null — `monthly` / `yearly` (used with `package_id`) |

Update is `POST /clients/{id}` + `_method=PUT` (multipart), same fields optional.

---

## 2.4 Invoices

Issued per billing cycle. Read + status transitions (mark paid / refund). No auto-generator in this
release (invoices are seeded / issued internally).

Permission: **`invoices.manage`**. List shape: **A (paginated + stats)**.

### `GET /api/v1/invoices`
Query: `?status=paid|unpaid|overdue|refunded` · `?client_id=<id>` · `?search=<number|client name|email>`
· `?per_page=` · `?page=` · `?export=csv|xlsx`.

> `status=overdue` is a **derived** filter (unpaid invoices past their `due_at`). On each item, `status`
> is also computed: an unpaid, past-due invoice is returned as `"overdue"`.

**Response 200:**
```json
{
  "status": 200,
  "message": "Invoices retrieved successfully",
  "data": {
    "meta": { "current_page": 1, "per_page": 10, "total": 13, "last_page": 2, "next_page_url": "...?page=2", "prev_page_url": null },
    "items": [
      {
        "id": 1,
        "number": "INV-2026-0001",
        "client_id": 2,
        "client_name": "Client User 1",
        "client_email": "client1@example.com",
        "package_name": "Wealth Manager",
        "tier": "WEALTH_MANAGER",
        "amount": 49,
        "currency_code": "AED",
        "status": "refunded",
        "issued_at": "2026-05-01",
        "due_at": "2026-05-08",
        "paid_at": "2026-05-06",
        "period_start": "2026-05-01",
        "period_end": "2026-06-01"
      }
    ]
  },
  "stats": {
    "collected": 738,
    "outstanding": 3316,
    "overdue": 3,
    "total": 13,
    "currency_code": "AED"
  },
  "errors": []
}
```
- `stats.collected` = Σ paid; `outstanding` = Σ unpaid; `overdue` = count of unpaid past due.

### `PATCH /api/v1/invoices/{id}` — transition
**Request body (JSON):**
```json
{ "status": "paid" }
```
Allowed transitions:
- `{ "status": "paid" }` — only from `unpaid` (or overdue) → sets `paid_at` today. Audits `invoice_paid`.
- `{ "status": "refunded" }` — only from `paid` → keeps `paid_at`. Audits `refund`.

Responses: `200` updated invoice · `404` not found · `422` `That status transition is not allowed.`

---

## 2.5 Coupons

Promo codes with a derived status. Permission: **`coupons.manage`**. List shape: **A (paginated + stats)**.

### `GET /api/v1/coupons`
Query: `?type=percent|fixed` · `?is_active=true|false` · `?search=<code|description>` · `?per_page=` ·
`?page=` · `?export=csv|xlsx`.

**Response 200:**
```json
{
  "status": 200,
  "message": "Coupons retrieved successfully",
  "data": {
    "meta": { "current_page": 1, "per_page": 10, "total": 6, "last_page": 1, "next_page_url": null, "prev_page_url": null },
    "items": [
      {
        "id": 6,
        "code": "BLACKFRIDAY",
        "description": "Black Friday (disabled)",
        "type": "percent",
        "value": 40,
        "currency_code": "AED",
        "duration": "once",
        "duration_months": null,
        "max_redemptions": 1000,
        "times_redeemed": 0,
        "starts_at": null,
        "expires_at": null,
        "is_active": false,
        "status": "disabled"
      }
    ]
  },
  "stats": { "total": 5, "active": 4, "redemptions": "296" },
  "errors": []
}
```
- `status` is **derived** (never stored), precedence: `disabled` → `exhausted` → `scheduled` →
  `expired` → `active`.
- **Note on `stats`:** `total` = all coupons, `active` = count where `is_active = true`,
  `redemptions` = Σ `times_redeemed` (returned as a **string**). (This is the backend's current output.)
- **`applies_to` is NOT included in the list** — only in the show endpoint (below).

### `GET /api/v1/coupons/{id}`
Same coupon object **plus** an expanded `applies_to`:
```json
{
  "status": 200,
  "message": "Coupon retrieved successfully",
  "data": {
    "id": 3, "code": "SUMMER25", "description": "Summer launch — 25% off",
    "type": "percent", "value": 25, "currency_code": "AED",
    "duration": "repeating", "duration_months": 3,
    "max_redemptions": 200, "times_redeemed": 0,
    "starts_at": "2026-07-07", "expires_at": "2026-10-07",
    "is_active": true, "status": "scheduled",
    "applies_to": [
      { "id": 2, "name": "Wealth Manager" },
      { "id": 3, "name": "White Label" }
    ]
  },
  "errors": []
}
```
- `applies_to` is `[]` when the coupon applies to **all plans**.

### `POST /api/v1/coupons`
**Request body (JSON):**
```json
{
  "code": "WELCOME20",
  "description": "20% off the first payment",
  "type": "percent",
  "value": 20,
  "currency_code": "AED",
  "duration": "once",
  "duration_months": null,
  "max_redemptions": 100,
  "applies_to": [2, 3],
  "starts_at": "2026-06-01",
  "expires_at": "2026-12-31",
  "is_active": true
}
```

| field | type | rule |
|---|---|---|
| `code` | string | req, ≤ 64, unique (stored upper-cased) |
| `description` | string | null, ≤ 255 |
| `type` | enum | req — `percent` / `fixed` |
| `value` | number | req, ≥ 0; if `percent`, ≤ 100 |
| `currency_code` | string | req, size 3 |
| `duration` | enum | req — `once` / `repeating` / `forever` |
| `duration_months` | int | null, ≥ 1 — **required if** `duration = repeating` |
| `max_redemptions` | int | null, ≥ 1 (`null` = unlimited) |
| `applies_to` | int[] | null — package ids (`[]` = all plans) |
| `starts_at` | date | null |
| `expires_at` | date | null — ≥ `starts_at` |
| `is_active` | bool | opt |

`times_redeemed` is backend-maintained (read-only). **Response 201** = the created coupon (no `applies_to`).

### `PUT /api/v1/coupons/{id}`
Same fields, all optional; `code` unique-ignoring-self. Returns updated coupon.

### `PATCH /api/v1/coupons/{id}` — toggle
No body. Flips `is_active`. Returns the updated coupon. `404` if not found.

### `DELETE /api/v1/coupons/{id}`
`200` deleted · `404` not found.

---

## 2.6 Dashboard insights

Read-only platform aggregation for the admin dashboard. Permission: **`dashboard.view`**.
Cached ~10 min.

### `GET /api/v1/admin/insights`
**Response 200 (`data`):**
```json
{
  "admin_name": "Super Admin",
  "platform_currency": "AED",
  "total_aum": 16055234.73,
  "aum_yoy": 0,
  "client_count": 7,
  "kpis": [
    { "key": "clients", "label": "Active clients", "value": "7", "suffix": "", "sub": "+0 this month", "tone": "blue" },
    { "key": "aum", "label": "Total AUM", "value": "AED 16.1M", "suffix": "", "sub": "0% YoY", "tone": "gold" },
    { "key": "mrr", "label": "MRR", "value": "AED 454.66", "suffix": "", "sub": "recurring", "tone": "green" },
    { "key": "new_clients", "label": "New clients", "value": "0", "suffix": "", "sub": "this month", "tone": "violet" }
  ],
  "growth": [
    { "month": "Jul", "clients": 0, "aum": 0, "new_clients": 0 }
    // 12 monthly points
  ],
  "allocation": [
    { "label": "Property", "value": 7.22, "color": "#C9A227" },
    { "label": "Land", "value": 6.12, "color": "#C9A227" },
    { "label": "Cash Account", "value": 2.33, "color": "#C9A227" },
    { "label": "Vehicle", "value": 0.39, "color": "#C9A227" }
  ],
  "revenue": [
    { "month": "Jul", "mrr": 0 }
    // 12 monthly points, mrr in thousands
  ],
  "top_clients": [
    { "id": 6, "name": "Karim Karam", "email": "karim.karam@wealthapp.com", "aum": 16055234.73, "assets": 17, "trend_pct": 0 }
  ],
  "activity": [
    { "id": 6, "actor": "Super Admin", "action": "updated", "target": "User #15", "tone": "blue", "at": "2026-06-07T11:38:32+00:00" }
  ]
}
```
Field notes:
- `total_aum` = sum of latest market value per top-level asset (book-value fallback), in platform currency.
- `kpis[].tone` ∈ `gold | green | blue | red | violet`.
- `growth[]` = 12 months; `clients` cumulative, `aum` in **millions**, `new_clients` that month.
- `allocation[].value` in **millions**. *(Colors are currently all `#C9A227` — assign your own chart palette client-side.)*
- `revenue[].mrr` in **thousands**.
- **`growth`/`revenue` series are zero-filled until the monthly snapshots are populated** (run the
  `CapturePlatformSnapshotJob` / `PlatformSnapshotSeeder`). The headline numbers (`total_aum`, `kpis`,
  `allocation`, `top_clients`, `activity`) are always live.

---

## 2.7 Revenue analytics

Read-only SaaS metrics page. Permission: **`revenue.view`**. Cached ~5 min.

### `GET /api/v1/admin/revenue`
**Response 200 (`data`):**
```json
{
  "summary": {
    "mrr": 454.66, "mrr_mom_pct": 0,
    "arr": 5455.92,
    "active_subscriptions": 4, "subs_delta": 4,
    "arpu": 113.67, "arpu_mom_pct": 0,
    "churn_rate": 0, "churn_delta_pct": 0,
    "nrr": 0, "grr": 0,
    "ltv": 2728.08, "quick_ratio": 0,
    "currency_code": "AED"
  },
  "trend": [ { "month": "Jul", "mrr": 0 } ],
  "by_plan": [
    { "tier": "WEALTH_MANAGER", "name": "Wealth Manager", "mrr": 89.83, "subscribers": 2, "share": 20 },
    { "tier": "WHITE_LABEL", "name": "White Label", "mrr": 364.83, "subscribers": 2, "share": 80 }
  ],
  "movement": { "starting": 0, "new": 0, "expansion": 0, "contraction": 0, "churned": 0 },
  "customers": [ { "month": "Jul", "gained": 0, "lost": 0, "active": 0 } ],
  "collections": [
    { "status": "paid", "count": 3, "amount": 738 },
    { "status": "pending", "count": 4, "amount": 787 },
    { "status": "overdue", "count": 3, "amount": 2529 }
  ],
  "at_risk": { "amount": 3316, "invoices": 7 }
}
```
- `summary` = live MRR/ARR/ARPU/churn/NRR/GRR/LTV/quick-ratio (active subs only).
- `by_plan` = live, grouped by active subscriptions; `share` is a whole %.
- `collections` = invoice buckets (`pending` = unpaid not-yet-due, `overdue` = unpaid past due).
- `at_risk` = Σ amount + count of all unpaid invoices.
- **`trend`, `customers`, `movement`, and the `*_mom_pct`/`nrr`/`grr` deltas come from monthly snapshots**
  — zero until snapshots are populated. `summary`, `by_plan`, `collections`, `at_risk` are always live.

---

## 2.8 Audit log

Read-only timeline of privileged actions. Permission: **`activity.view`**. List shape: **A (paginated + stats)**.

### `GET /api/v1/admin/audit`
Query: `?category=Access|Billing|Team|Platform|Clients|Comms` · `?action=<action key>` ·
`?search=<actor|target|detail>` · `?per_page=` · `?page=` · `?export=csv|xlsx`.

**Response 200:**
```json
{
  "status": 200,
  "message": "Audit entries retrieved successfully",
  "data": {
    "meta": { "current_page": 1, "per_page": 10, "total": 3, "last_page": 1, "next_page_url": null, "prev_page_url": null },
    "items": [
      {
        "id": 2,
        "actor_name": "Super Admin",
        "actor_role": "Super Admin",
        "action": "plan_change",
        "category": "Billing",
        "target": "Overdue Test Client",
        "detail": "Subscription #6 set to White Label (yearly, active).",
        "ip": "127.0.0.1",
        "created_at": "2026-06-07T10:13:16+00:00"
      }
    ]
  },
  "stats": { "total": 3, "impersonations": 0, "billing": 3 },
  "errors": []
}
```
Read-only — no create/update/delete. `action` values & categories are in the appendix.

---

## 2.9 Permissions & roles matrix

Drives the "Roles & Permissions" screen and the admin permission picker.
Permission: **`admins.manage`**.

### `GET /api/v1/admin/permissions`
The full permission catalogue (grouped) + the preset bundles.
**Response 200 (`data`):**
```json
{
  "groups": [
    { "name": "Overview", "permissions": [ { "key": "dashboard.view", "label": "View platform dashboard" } ] },
    { "name": "Revenue", "permissions": [
      { "key": "revenue.view", "label": "View revenue analytics" },
      { "key": "packages.manage", "label": "Manage packages" },
      { "key": "subscriptions.manage", "label": "Manage subscriptions" },
      { "key": "invoices.manage", "label": "Manage invoices" },
      { "key": "coupons.manage", "label": "Manage coupons" }
    ]},
    { "name": "Clients", "permissions": [
      { "key": "clients.view", "label": "View clients" },
      { "key": "clients.manage", "label": "Create & edit clients" },
      { "key": "clients.impersonate", "label": "View as client (impersonate)" }
    ]},
    { "name": "Communication", "permissions": [
      { "key": "push.send", "label": "Send push notifications" },
      { "key": "support.handle", "label": "Handle support conversations" }
    ]},
    { "name": "Team & platform", "permissions": [
      { "key": "admins.manage", "label": "Manage admins & roles" },
      { "key": "settings.manage", "label": "Manage platform settings" },
      { "key": "activity.view", "label": "View activity logs" }
    ]}
  ],
  "presets": {
    "finance": ["dashboard.view","revenue.view","packages.manage","subscriptions.manage","invoices.manage","coupons.manage","clients.view"],
    "support": ["dashboard.view","clients.view","clients.impersonate","push.send","support.handle"],
    "read_only": ["dashboard.view","revenue.view","clients.view","activity.view"]
  }
}
```

### `GET /api/v1/admin/roles`
The role cards + the per-role checkbox matrix.
**Response 200 (`data`):**
```json
{
  "roles": [
    {
      "key": "super_admin", "name": "Super Admin",
      "description": "Full access to everything. The B&G owner role.",
      "color": "gold", "is_owner": true, "locked": true,
      "members_count": 1, "permissions_count": 14, "total_permissions": 14,
      "permissions": ["dashboard.view","revenue.view","packages.manage","subscriptions.manage","invoices.manage","coupons.manage","clients.view","clients.manage","clients.impersonate","push.send","support.handle","admins.manage","settings.manage","activity.view"]
    },
    { "key": "finance", "name": "Finance", "description": "Revenue, billing, and invoices — no platform settings.",
      "color": "blue", "is_owner": false, "locked": false, "members_count": 0,
      "permissions_count": 7, "total_permissions": 14,
      "permissions": ["dashboard.view","revenue.view","packages.manage","subscriptions.manage","invoices.manage","coupons.manage","clients.view"] },
    { "key": "support", "name": "Support Agent", "description": "Handles client conversations and can view as a client.",
      "color": "violet", "is_owner": false, "locked": false, "members_count": 0,
      "permissions_count": 5, "total_permissions": 14,
      "permissions": ["dashboard.view","clients.view","clients.impersonate","push.send","support.handle"] },
    { "key": "read_only", "name": "Read-only", "description": "Can view dashboards and clients, but change nothing.",
      "color": "green", "is_owner": false, "locked": false, "members_count": 0,
      "permissions_count": 4, "total_permissions": 14,
      "permissions": ["dashboard.view","revenue.view","clients.view","activity.view"] }
  ],
  "matrix": {
    "groups": [
      {
        "name": "Overview",
        "permissions": [
          {
            "key": "dashboard.view",
            "label": "View platform dashboard",
            "roles": { "super_admin": true, "finance": true, "support": true, "read_only": true }
          }
        ]
      }
      // … Revenue, Clients, Communication, Team & platform — each permission row has a `roles` map
    ]
  }
}
```
- `members_count`: Super Admin = users with `role.super_admin`; the presets count admins whose **exact**
  direct-permission set matches the preset bundle (0 until you assign presets).

---

## 2.10 Manage admins

CRUD for B&G staff accounts, with per-admin permission assignment. Permission: **`admins.manage`**.
List shape: standard paginated `{ meta, items }` (no `stats`).

### Admin object
```json
{
  "id": 15,
  "name": "Jane Finance",
  "email": "jane.finance@example.com",
  "phone": "+971500000000",
  "image_url": null,
  "preferred_currency_id": 1,
  "roles": ["role.admin"],
  "permissions": ["dashboard.view", "clients.view"],
  "created_at": "2026-06-07T11:37:12+00:00",
  "updated_at": "2026-06-07T11:38:32+00:00"
}
```

### `GET /api/v1/admins`
Query: `?per_page=` · `?page=`. Returns paginated admins.

### `GET /api/v1/admins/{id}`
One admin (object above). `404` if not found.

### `POST /api/v1/admins` (multipart/form-data — or JSON if no image)
| field | type | rule |
|---|---|---|
| `name` | string | req |
| `email` | string | req — unique in `users` |
| `phone` | string | null |
| `image` | file | opt — image, ≤ 2 MB |
| `preferred_currency_id` | int | null — exists in `currencies` |
| `permissions` | string[] | opt — each must be a valid permission key |
| `preset` | enum | opt — `finance` / `support` / `read_only` (expanded server-side; ignored if `permissions` sent) |

The new admin gets `role.admin` + the assigned permissions, and is emailed an invitation with a generated
password. **Response 201** = the admin object.

JSON example:
```json
{ "name": "Jane Finance", "email": "jane.finance@example.com", "phone": "+971500000000",
  "preferred_currency_id": 1, "permissions": ["dashboard.view", "revenue.view", "invoices.manage"] }
```
Or via a preset: `{ "name": "...", "email": "...", "preset": "finance" }`.

### `PUT /api/v1/admins/{id}` (or `POST` + `_method=PUT`)
Same fields, all optional. **Permissions are only changed when `permissions` (or `preset`) is sent** —
omit them to leave the admin's access untouched. `syncPermissions` replaces the whole set. Returns the
updated admin.

### `DELETE /api/v1/admins/{id}`
`200` deleted · `404` not found · `422` `You cannot delete your own account`.

---

## 2.11 Push notifications

Admin-authored broadcasts, delivered to a **targeted audience** (in-app notification + FCM push).
Send-once and immutable — no edit/delete. Permission: **`push.send`**.
List shape: standard paginated `{ meta, items }` (no `stats`), newest first.

### Notification object
```json
{
  "id": 12,
  "title": "Scheduled maintenance",
  "body": "We'll be down Sat 2–3am.",
  "audience": { "type": "status", "statuses": ["active"] },
  "recipients_count": 4,
  "created_at": "2026-06-04T10:12:00.000000Z"
}
```
- `audience` — the segment that was targeted, or `null` when it went to everyone.
- `recipients_count` — **backend-computed at send time** = the size of the resolved audience
  (read-only; never send it).

### `GET /api/v1/push-notifications`
Query: `?per_page=` · `?page=`. Returns paginated notification objects (shape above).

### `GET /api/v1/push-notifications/{id}`
One notification (same shape). `404` if not found.

### `POST /api/v1/push-notifications`
**Request body (JSON):**
```json
{
  "title": "Scheduled maintenance",
  "body": "We'll be down Sat 2–3am for upgrades.",
  "audience": { "type": "all", "tiers": [], "statuses": [] }
}
```

| field | type | rule |
|---|---|---|
| `title` | string | req, ≤ 255 |
| `body` | string | req, ≤ 500 |
| `audience` | object | **optional** — omit (or `type:"all"`) to broadcast to everyone |
| `audience.type` | enum | required when `audience` is sent — `all` / `plan` / `status` |
| `audience.tiers` | string[] | **required if** `type=plan` — values from `TRACK_ANALYZE` / `WEALTH_MANAGER` / `WHITE_LABEL` |
| `audience.statuses` | string[] | **required if** `type=status` — values from `trialing` / `active` / `past_due` / `cancelled` |

**Audience resolution (who receives it):**
- `type: "all"` (or no audience) → every user. `tiers`/`statuses` are ignored.
- `type: "plan"` → users whose subscription tier is in `tiers`.
- `type: "status"` → users whose subscription status is in `statuses`.

**Response 201** = the created notification object (with the resolved `recipients_count`). Delivery is
fanned out server-side to the same audience; the response does not wait on per-device delivery. Sending
also writes a `push_sent` audit entry.

> Send these audience values exactly: `{ "type": "all" | "plan" | "status", "tiers": [...], "statuses": [...] }`.
> Example targeted send: `{ "title": "...", "body": "...", "audience": { "type": "plan", "tiers": ["WEALTH_MANAGER"] } }`.

---

# 3. Appendices

## Enums & constants

**Package tier** (`tier`): `TRACK_ANALYZE` · `WEALTH_MANAGER` · `WHITE_LABEL`
→ maps to roles `package.track_analyze` · `package.wealth_manager` · `package.agency`.

**Subscription status:** `trialing` · `active` · `past_due` · `cancelled`
**Billing interval:** `monthly` · `yearly`
**Invoice status:** `paid` · `unpaid` · `overdue` (derived) · `refunded`
**Coupon type:** `percent` · `fixed`
**Coupon duration:** `once` · `repeating` · `forever`
**Push audience type:** `all` · `plan` (uses `tiers[]`) · `status` (uses `statuses[]`)
**Coupon derived status** (precedence): `disabled` → `exhausted` → `scheduled` → `expired` → `active`

**Permission keys (14):** `dashboard.view`, `revenue.view`, `packages.manage`, `subscriptions.manage`,
`invoices.manage`, `coupons.manage`, `clients.view`, `clients.manage`, `clients.impersonate`,
`push.send`, `support.handle`, `admins.manage`, `settings.manage`, `activity.view`.

**Audit actions → category:**

| action | category |
|---|---|
| `impersonation_start`, `impersonation_end`, `login` | Access |
| `plan_change`, `refund`, `invoice_paid` | Billing |
| `role_change` | Team |
| `settings_change` | Platform |
| `client_create` | Clients |
| `push_sent` | Comms |

## Error responses

All errors use the standard envelope. Examples:

**401 (missing/invalid token)** — clear auth and redirect to login.
```json
{ "status": 401, "message": "Unauthenticated.", "data": null }
```

**403 (missing permission)**
```json
{ "status": 403, "message": "User does not have the right permissions.", "data": [] }
```

**404 (not found)**
```json
{ "status": 404, "message": "Package not found", "data": {}, "errors": [] }
```

**409 (conflict, e.g. deleting a package with subscriptions)**
```json
{ "status": 409, "message": "This package has subscriptions and cannot be deleted. Archive it (is_active=false) instead.", "data": {}, "errors": [] }
```

**422 (validation)** — `errors` is a field→messages map.
```json
{
  "status": 422,
  "message": "The given data was invalid.",
  "data": {},
  "errors": {
    "tier": ["The selected tier is invalid."],
    "price_monthly": ["The price monthly field is required."]
  }
}
```

---

*Generated from the live backend (controllers + resources + form requests) with real seeded payloads.
A Postman collection (`WM Package App`) mirrors every endpoint here.*
