# Vertex WiFi — API Reference

> Deliverable 4 of 13. All 72 routes under `/api/v1`.

Base URL: `{APP_URL}/api/v1`

---

## Conventions

**Auth** — `Authorization: Bearer <token>` on everything except `POST /login`.

**Content** — JSON in, JSON out. `ForceJsonResponse` middleware sets
`Accept: application/json` on every API request, so a failure before the controller
runs still returns JSON rather than Laravel's HTML error page.

**Enums** — every enum field is an envelope, so the client never keeps its own copy
of a status list:

```json
{ "status": { "value": "active", "label": "Active", "color": "emerald" } }
```

**Pagination** — Laravel's standard `{ data, links, meta }`. Endpoints add extra
keys to `meta` (status counts, period totals, filter option lists).

**Money** — serialised as JSON numbers, computed server-side with `bcmath`.

### Status codes

| Code | Meaning |
| --- | --- |
| 200 | OK |
| 201 | Created |
| 202 | Accepted — queued (SMS send) |
| 401 | No/expired token → `{"message":"Unauthenticated."}` |
| 403 | Policy denied. Often carries a *specific* reason, e.g. *"Payment RCP-… was already voided on Aug 7, 2026"* |
| 404 | Not found → `{"message":"Resource not found."}` |
| 422 | Validation **or a business-rule refusal** (see below) |
| 429 | Rate limited; `Retry-After` header set |

`422` covers two things. Field validation:

```json
{ "message": "The contact number must be a valid Philippine mobile number, for example 09171234567.",
  "errors": { "contact_number": ["The contact number must be a valid…"] } }
```

…and a `DomainException` — a business rule, not a bug:

```json
{ "message": "A Disconnected account cannot be moved to Active. Allowed: Pending." }
```

### Rate limits

Named limiters, each with its **own** bucket (`RateLimitServiceProvider`):

| Limiter | Limit | Applies to |
| --- | --- | --- |
| `api` | 180/min per user | everything authenticated |
| `login` | 10/min per IP | `POST /login` |
| `sms-send` | 30/min | send + resend |
| `sms-test` | 6/min | test send |
| `exports` | 30/min | `GET /reports/{report}` |
| `gateway-status` | 20/min | gateway poll |
| `credentials` | 6/min | password change / admin reset |

> Laravel's inline `throttle:x,y` keys on the **user id alone** — the route is not
> part of the key — so all inline-throttled routes share one counter and the
> tightest limit wins. Running a few reports would have locked an operator out of
> sending a test SMS. Named limiters get separate buckets.

---

## Authentication

### `POST /login`
No auth. Rate limit: `login`.

```json
{ "email": "admin@vertexwifi.ph", "password": "…", "device_name": "Chrome on Windows" }
```

**200**
```json
{
  "message": "Signed in.",
  "token": "4|LPd9ytKGX4XuG7Bl…",
  "token_type": "Bearer",
  "expires_at": "2026-08-07T13:12:31+08:00",
  "user": { "id": 2, "name": "Vertex Admin", "role": "Admin", "permissions": ["dashboard.view", "…"] }
}
```

`permissions` is only present on `/login` and `/me` — the UI drives every gate from it.

> **There is no `POST /register`, and there must never be.** Subscribers never hold
> accounts; staff accounts are created via `POST /users` by a Super Admin.

| Route | Purpose |
| --- | --- |
| `GET /me` | Rehydrate the session from a stored token (with permissions) |
| `POST /logout` | Revoke **this** token only |
| `POST /logout-all` | Revoke every token for the account |
| `POST /change-password` | Requires `current_password`; drops other sessions |

---

## Dashboard

| Route | Returns |
| --- | --- |
| `GET /dashboard` | Everything: `stats`, `charts`, `notifications`, `panels`. `?fresh=1` bypasses the 60s cache |
| `GET /dashboard/stats` | Counters only — for a lightweight poll |
| `GET /dashboard/charts` | The four chart series |
| `GET /dashboard/notifications` | Actionable alerts (drives the bell badge) |

One request rather than eight: all of it derives from the same cached aggregate, so
splitting it would pay the aggregation cost four times.

`stats` includes `total_customers`, `active_customers`, `inactive_customers`,
`disconnected_customers`, `pending_customers`, `installed_customers`,
`suspended_customers`, `new_installations_this_month`, `bills_due_today`,
`bills_due_in_3_days`, `monthly_revenue`, `last_month_revenue`,
`expected_revenue`, `recurring_monthly_value`, `sms_sent_today`,
`sms_failed_today`, `currency`.

> **"Inactive"** means every account that is neither active nor disconnected —
> pending, installed and suspended. Those are subscribers who exist but are not
> carrying revenue, which is the number an operator wants.

`charts` → `monthly_revenue` (12 months, zero-filled), `customer_growth`
(cumulative + per-month), `status_breakdown`, `plan_distribution`.

`notifications` only includes alerts with a non-zero count — a wall of zeroes trains
people to ignore the panel.

---

## Customers

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/customers` | Paginated, searchable, filterable |
| GET | `/customers/filter-options` | Barangays, cities, statuses, sortable columns — one call for the filter bar |
| POST | `/customers` | Creates customer + connection + subscription **atomically** |
| GET | `/customers/{id}` | Full profile with all relations |
| PATCH | `/customers/{id}` | Partial; unsent fields untouched |
| DELETE | `/customers/{id}` | Soft delete; **refused while a balance is outstanding** |
| POST | `/customers/bulk-delete` | Archive a selection; per-record verdict |
| POST | `/customers/{id}/restore` | |
| PATCH | `/customers/{id}/status` | The only way status changes |

### `GET /customers` — query parameters

| Param | Notes |
| --- | --- |
| `search` | Name, account no., phone, alt phone, email, address, barangay — **and** router serial, ONU serial, MAC, PPPoE username, IP, plan name via joins |
| `status` | `pending` `installed` `active` `suspended` `disconnected` |
| `plan_id`, `barangay`, `city` | |
| `due_from` / `due_to` | Next due date range |
| `installed_from` / `installed_to` | Installation date range |
| `sort_by` | Whitelisted: `created_at` `full_name` `account_number` `barangay` `city` `status`. Anything else falls back — an unchecked `orderBy` is an injection vector |
| `sort_dir` | `asc` / `desc` |
| `per_page` | 1–100, default 15 |

`meta.status_counts` comes back with every response, so the status chips show live
counts without a second request.

### `POST /customers`

One payload, three sections — so a half-encoded record is impossible:

```json
{
  "full_name": "Juan Miguel Dela Cruz",
  "address": "128 Rizal Street", "barangay": "Poblacion I",
  "city": "Sta. Cruz", "province": "Laguna",
  "contact_number": "0917 123 4567",
  "email": "juan@example.ph",
  "gps_latitude": 14.2789456, "gps_longitude": 121.4162318,

  "connection": {
    "router_brand": "TP-Link", "router_model": "Archer C6",
    "router_serial_number": "RTR001234",
    "mac_address": "aa-bb-cc-11-22-33",
    "pppoe_username": "VTX0001", "pppoe_password": "Secret#123",
    "nap_location": "NAP-01 Rizal St.", "port_number": "4"
  },

  "subscription": {
    "plan_id": 2, "billing_cycle": "monthly", "billing_day": 15,
    "installation_date": "2026-08-07"
  }
}
```

Normalisation applied on the way in:

| Input | Stored |
| --- | --- |
| `0917 123 4567` / `+63 917 123 4567` / `9171234567` | `09171234567` |
| `aa-bb-cc-11-22-33` | `AA:BB:CC:11:22:33` |
| `VTX0001` | `vtx0001` (PPPoE is case-insensitive on any OLT worth using) |
| router/ONU serials | Uppercased — they are printed on stickers in caps |

`account_number` is generated as `VTX-{YYYY}-{00001}` inside the transaction, with a
gap lock plus retry-on-duplicate so two encoders saving simultaneously cannot
collide.

New subscribers may only start `pending` or `installed` — going straight to `active`
would skip `CustomerStatusService` and leave the subscription without an activation
date or first due date.

### `POST /customers/bulk-delete`

Backs the checkbox selection on the customer list.

```json
{ "ids": [12, 13, 14] }
```

**200** — always, even when nothing was archived:

```json
{
  "data": {
    "archived": [ { "id": 12, "account_number": "VTX-2026-00012", "full_name": "Divina Grace Alonzo" } ],
    "skipped":  [ { "id": 13, "account_number": "VTX-2026-00013", "full_name": "Roberto Bautista Reyes",
                    "reason": "Still owes 2,698.50 across 2 open invoice(s). Disconnect the account instead." } ]
  },
  "message": "1 archived, 1 skipped."
}
```

**Partial success is the designed outcome, not a compromise.** The same rule as the
single-record delete applies to every id — a subscriber who still owes money cannot
be archived — so a real selection routinely contains one that must be refused.
Wrapping the batch in one transaction would let a single debtor silently block the
other nineteen, leaving the operator to bisect the selection by hand to find out
why. Each record is archived in its own transaction and the caller gets a verdict
per id.

Not 207 Multi-Status: the request itself succeeded, and the body already carries the
detail. A 207 would push every client into special-case handling for the normal path.

Other guarantees:

- **Every record is re-authorised individually** against `CustomerPolicy::delete`.
  Arriving through the bulk endpoint cannot bypass a check `DELETE /customers/{id}`
  would have applied.
- **Max 100 ids**, matching the largest page the UI can select. An unbounded list
  turns one request into an arbitrarily long write transaction.
- **Duplicates collapse** to a single record before processing.
- **Already-archived ids are rejected** (422) rather than reported as succeeding.
- Balances are aggregated in **one** query (`withSum`), not one per selected row.
- Every archive writes an `activity_logs` row, as the single-record delete does.

Requires `customers.delete`.

### `PATCH /customers/{id}/status`

```json
{ "status": "suspended", "reason": "Unpaid balance beyond grace period.", "notify": true }
```

- Illegal transitions are refused with the legal set named in the message.
- `reason` is **required** for `suspended` and `disconnected`.
- `notify: true` sends the matching SMS where a template exists (activation,
  suspension). A gateway failure never rolls back the status change.

Legal transitions:

```
pending      → installed, active, disconnected
installed    → active, disconnected
active       → suspended, disconnected
suspended    → active, disconnected
disconnected → pending          (a dead line needs re-installation)
```

---

## Nested under a customer

| Method | Route |
| --- | --- |
| GET / POST | `/customers/{id}/notes` |
| GET / POST | `/customers/{id}/subscriptions` |
| GET | `/customers/{id}/invoices/outstanding` |
| POST | `/customers/{id}/invoices` — issue one on demand |
| GET | `/customers/{id}/activity` — audit trail for this record |

## Notes

| Method | Route |
| --- | --- |
| PATCH | `/notes/{note}` — author only, 30-minute window |
| PATCH | `/notes/{note}/pin` |
| DELETE | `/notes/{note}` |

## Subscriptions

| Method | Route | Notes |
| --- | --- | --- |
| PATCH | `/subscriptions/{id}` | Corrections only; a `plan_id` change is refused here |
| POST | `/subscriptions/{id}/change-plan` | **201** — closes the old agreement, opens a new one |

A plan change is not an update. The old row is retained with its original price so
invoices already issued keep pointing at the price that produced them.

---

## Plans

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/plans` | With `subscribers_count` |
| GET | `/plans/selectable` | Active plans, display order — for selects |
| GET | `/plans/distribution` | Subscribers + recurring value per plan |
| POST | `/plans` | |
| GET / PATCH | `/plans/{id}` | |
| DELETE | `/plans/{id}` | **Refused while subscribers remain** |
| POST | `/plans/{id}/restore` | |
| PATCH | `/plans/{id}/toggle-status` | |

Editing a price affects **new** subscriptions only. Archiving with subscribers
returns 422: *"4 subscriber(s) are still on 'Vertex Starter 20'. Move them to
another plan first, or set this plan to Inactive to hide it from new signups."*

---

## Invoices

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/invoices` | `meta.outstanding_total` included |
| GET | `/invoices/{id}` | With payments |
| POST | `/invoices/{id}/void` | Requires `reason`; refused once money has landed |
| POST | `/invoices/{id}/recalculate` | Repair hatch — re-derives from payments on record |

Filters: `search`, `status`, `customer_id`, `outstanding=1`, `due_from`, `due_to`,
`sort_by` (`due_date` `issued_date` `total_amount` `balance`).

`total_amount = amount_due − discount + installation_fee`, and
`balance = total_amount − amount_paid`. Both are **recomputed** from the payments on
record rather than incremented — increments drift the moment a payment is voided.

---

## Payments

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/payments` | `meta.period_total` for the date range |
| POST | `/payments` | **201** |
| GET | `/payments/{id}` | |
| POST | `/payments/{id}/void` | Requires `reason` |
| GET | `/payments/{id}/receipt` | PDF. `?stream=1` renders inline for printing |

```json
{ "customer_id": 1, "invoice_id": 3, "amount": 1799.00,
  "payment_date": "2026-08-07", "payment_method": "gcash",
  "reference_number": "9F2A1B7C", "remarks": "Paid at office" }
```

- Omitting `invoice_id` settles the **oldest open invoice** — what a cashier means
  by "he paid his bill".
- `payment_date` cannot be in the future.
- Every non-cash method **requires** `reference_number`, so it can be reconciled
  against the e-wallet statement.
- Recording or voiding a payment recalculates the invoice in the same transaction.

Voiding never deletes: the row stays with its reason and the balance is restored. A
receipt already in a subscriber's hands must remain explainable. Voiding twice
returns 403 with the original void date.

The receipt PDF prints as two identical stubs — subscriber copy and office copy —
because that is how field collection actually works.

---

## Reports

| Method | Route |
| --- | --- |
| GET | `/reports` — the catalogue, with each report's parameters |
| GET | `/reports/{report}` |

| Report key | Parameters |
| --- | --- |
| `monthly-revenue` | `year`, `month` |
| `collection` | `from`, `to` |
| `unpaid-bills` | — |
| `due-soon` | `days` (1–180) |
| `customer-growth` | `months` (1–60) |
| `installations` | `from`, `to` |
| `customer-master-list` | the full customer filter set |

Add `?format=pdf|xlsx|csv` to download instead of receiving JSON. Rate limit
`exports`; requires `reports.export` — a separate permission from `reports.view`.

`months` and `days` are **capped**. An unbounded `months=100000` would build a
100,000-row zero-filled series and exhaust memory — the cheapest DoS in any
reporting endpoint.

Every report returns the same envelope, which is why one exporter handles all seven:

```json
{ "title": "Unpaid Bills", "period": "As of Aug 7, 2026",
  "columns": { "invoice_number": "Invoice No.", "balance": "Balance" },
  "rows": [ { "invoice_number": "INV-2026-00003", "balance": 1799 } ],
  "totals": { "balance": 20592 },
  "money_columns": ["balance"],
  "summary": { "by_ageing": { "1-30 days": { "count": 4, "balance": 7196 } } },
  "row_count": 9, "currency": "₱", "company": "Vertex WiFi",
  "generated_at": "Fri, Aug 7, 2026 1:12 AM" }
```

The `customer-master-list` export accepts the same filters as `GET /customers`, so a
downloaded file always matches the rows the user was looking at.

---

## SMS

| Method | Route | Rate limit |
| --- | --- | --- |
| GET | `/sms/logs` | `api` |
| GET | `/sms/logs/{id}` | `api` |
| POST | `/sms/send` | `sms-send` — **202** |
| POST | `/sms/test` | `sms-test` — synchronous |
| POST | `/sms/logs/{id}/resend` | `sms-send` |
| GET | `/sms/gateway/status` | `gateway-status` |
| GET | `/sms/gateway/drivers` | `api` |
| GET | `/sms/templates` | `api` |
| PATCH | `/sms/templates/{id}` | `api` |
| GET | `/sms/templates/{id}/preview` | `api` |
| GET / POST | `/sms/device/token` | `credentials` on POST |
| GET | `/sms/device/pending` | `api` |

### Device gateway (a phone sending over its own SIM)

Three routes that live **outside** the main authenticated group, gated on a Sanctum
token carrying only the `sms:device` ability:

| Method | Route | Purpose |
| --- | --- | --- |
| GET/POST | `/sms/device/ping` | Health check; also records a heartbeat |
| GET/POST | `/sms/device/pull` | Claim up to 20 queued messages |
| POST | `/sms/device/ack` | Report one message as `sent` or `failed` |

```jsonc
// POST /sms/device/pull   { "device": "My Phone", "battery": 82, "limit": 10 }
{ "messages": [ { "id": 41, "to": "+639171234567", "text": "Hello …" } ],
  "count": 1, "paused": false }

// POST /sms/device/ack    { "id": 41, "status": "sent" }
{ "message": "Recorded.", "status": "sent" }
```

**Token scoping is the point.** The token gets pasted into a shortcut on a phone
sitting on a desk, so it must be worth nothing beyond sending the messages it is
handed. `abilities:*` on the main API group shuts it out of everything else — the
test suite asserts a device token gets **403** on customers, payments, settings, SMS
logs, `/me`, and on issuing another token. Losing that token leaks no data.

**Delivery is at-least-once.** A pulled message is leased (default 5 minutes,
`sms_device_lease_minutes`) so a device polling every 30 seconds is not handed the
same row repeatedly. If the device sends but dies before acking, the lease expires
and the message is re-offered. For a billing reminder that is the right trade: a
duplicate is an annoyance, a silently dropped reminder is an unpaid bill.

`pull` returns `paused: true` and an empty list when SMS is switched off or a
different gateway is selected, so flipping the master switch actually stops the
phone rather than letting it drain the queue.

`pull` also accepts **`peek: true`** — return the batch without claiming it. A dry
run must use this. A dry run that pulls normally takes a pickup lease on every
message it displays, so the live run a few seconds later is handed nothing and
reports "Nothing to send", which reads as a broken gateway to the person who just
watched the dry run list two messages. Responses carry `peeked` and **`held`** (rows
queued but lease-held by a recent pull), so a client seeing `count: 0` can say
"another run holds them" instead of "nothing to send".

Issuing a token (`POST /sms/device/token`) returns the plaintext **once** and
revokes any previous device tokens — which is also how a lost handset is handled.

`/sms/test` runs **synchronously** rather than queueing, so the Settings page can
report a real gateway result while the admin is still looking at it. A queued test
that silently fails ten minutes later teaches nobody anything.

`POST /sms/logs/{log}/resend` goes through `SmsService::requeue()`, which does three
things a bare status reset does not: it **re-stamps `driver`** to whatever gateway is
active now (the row records the driver that failed it, and the outbox only offers rows
whose driver is `device`), it **clears the pickup lease**, and it **dispatches no job
for a pull gateway** — `DeviceGatewayDriver::send()` is a permanent failure by design,
so dispatching would re-fail the message while the phone was about to collect it. The
202 response says which happened.

`/sms/gateway/status` returns reachability plus, where the driver reports them,
battery level, charging state, signal strength and latency. Those are `null` for
hosted providers — there is no handset — and the UI omits the tile rather than
showing a fabricated reading.

Template bodies support `{customer_name}` `{account_number}` `{amount}`
`{due_date}` `{plan}` `{invoice_number}` `{company}`. An unrecognised placeholder is
**rejected on save**: a typo like `{customer_nmae}` passes every other check and
then ships literally, in every reminder, until somebody notices.

`meta.summary` on the log list gives counts by status for the tiles.

---

## Settings

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/settings` | Grouped: `branding`, `billing`, `sms`. `?group=sms` to narrow |
| PUT | `/settings` | Bulk save; requires `settings.update` |
| GET | `/settings/public` | Company name, currency symbol, reminder lead time — readable by any role |

```json
{ "settings": { "company_name": "Vertex WiFi", "sms_driver": "android",
                "sms_android_url": "http://192.168.1.50:8080" } }
```

Secret handling, which the smoke test asserts:

- A secret's **value is never returned** — the response carries `value: null` plus
  `is_set: true/false`, so the UI shows a "Set / Not set" pill and an empty field.
  Echoing an API key back puts it in browser memory, the network log, and any error
  report that captures the response.
- **Submitting a blank secret leaves the stored value untouched.** The form renders
  those fields empty on every load, so treating blank as "clear" would wipe the
  gateway key on any unrelated save.
- Unknown keys are rejected — the seeder defines the schema.

Each row also carries two fields the SMS panel uses to decide what to render:

| Field | Meaning |
| --- | --- |
| `driver` | Which gateway the setting belongs to, derived from its key prefix (`sms_twilio_*` → `"twilio"`). `null` for driver-agnostic settings like `sms_enabled`. The UI hides rows whose `driver` is not the selected one, so a Twilio SID cannot be typed into a Semaphore setup |
| `is_managed` | `true` for the six keys the *app* writes — device heartbeat and gateway-poll state (`Setting::MANAGED_KEYS`). Never rendered as an input |

A `PUT` containing a managed key is rejected rather than ignored, so an API client gets
the same answer the UI does:

```
422  These are written automatically and cannot be edited: sms_device_battery
```

Settings win over `config/sms.php` at runtime, so switching gateway or rotating a
key needs no redeploy.

---

## Users

| Method | Route | Notes |
| --- | --- | --- |
| GET | `/users` | `meta.roles` includes each role's permission count |
| GET | `/users/roles` | Full role → permission matrix |
| POST | `/users` | **The only way an account is created** |
| GET / PATCH / DELETE | `/users/{id}` | |
| PATCH | `/users/{id}/toggle-active` | Deactivating deletes their tokens immediately |
| POST | `/users/{id}/reset-password` | Rate limit `credentials`; drops all their sessions |

Every user row carries a `can` object (`update`, `delete`, `toggle_active`,
`assign_role`) so the UI renders only the actions that will actually succeed. The
policy still enforces them: nobody may deactivate themselves, only a Super Admin may
touch another Super Admin, and the last Super Admin cannot be removed or demoted.

## Activity log

| Method | Route |
| --- | --- |
| GET | `/activity-logs` — Super Admin only |
| GET | `/customers/{id}/activity` |

Filters: `event`, `user_id`, `subject_type` (short name, e.g. `Customer`), `from`,
`to`, `search`. `old_values`/`new_values` are JSON **diffs**, not full snapshots,
with `password`, `remember_token` and `pppoe_password` stripped globally.

---

## Fallback

Any unmatched `/api/*` path returns JSON, not Laravel's HTML 404:

```json
{ "message": "Endpoint not found. See docs/05-API-ROUTES.md for the available routes." }
```
