# Vertex WiFi — Backend Structure

> Deliverable 2 of 13. The clean-architecture layout, and the rule for deciding
> where a piece of code belongs.

---

## 1. The one rule

**Controllers never contain business logic and never build queries.**

A controller translates HTTP to a service call and back. That is the whole job. Every
time that rule is followed, three things become possible: a business rule has exactly
one home, a query can be reused by the list *and* the export without drifting, and a
service can be driven from a console command or a seeder without inventing a fake
request.

```
Request
  │
  ▼
Form Request ──────────► validates shape, authorises via policy
  │
  ▼
Controller ────────────► translates HTTP; no rules, no queries
  │
  ├──► DTO             ─► typed payload (no loose arrays past this point)
  │
  ▼
Service ───────────────► ALL business rules; owns transactions
  │
  ├──► Repository      ─► ALL query construction
  │
  ▼
Model ─────────────────► relationships, casts, scopes
  │
  ▼
API Resource ──────────► shapes the response
```

### Where does this code go?

| If it… | It belongs in |
| --- | --- |
| decides whether something is *allowed* | Policy |
| validates the *shape* of input | Form Request |
| decides what *should happen* | Service |
| builds a *query* | Repository (or a model scope it calls) |
| carries data *between* layers | DTO |
| describes a *row* | Model |
| shapes a *response* | API Resource |
| is a named set of values | Enum |

---

## 2. Layout

```
app/
├── Console/Commands/          5 scheduled commands
│   ├── GenerateMonthlyInvoices.php
│   ├── SendDueDateReminders.php        ← the spec's nightly requirement
│   ├── SuspendOverdueAccounts.php     ← opt-in
│   ├── CheckSmsGateway.php
│   └── PruneActivityLogs.php
│
├── DTOs/                     typed payloads
│   ├── CustomerData.php  ConnectionData.php  SubscriptionData.php
│   ├── PaymentData.php
│   └── CustomerFilters.php            ← search/filter state as one object
│
├── Enums/                    12 backed enums — single source of truth
│   ├── CustomerStatus.php              (+ legal transitions)
│   ├── Permission.php                  (38 cases)
│   ├── UserRole.php                    (role → permission matrix)
│   └── … BillingCycle, InvoiceStatus, PaymentMethod, PaymentStatus,
│         PlanStatus, SubscriptionStatus, SmsStatus, SmsType,
│         NoteCategory, SettingType
│
├── Exceptions/
│   └── DomainException.php            ← business-rule refusal → clean 422
│
├── Exports/ReportExport.php           ← one exporter, all seven reports
│
├── Http/
│   ├── Controllers/Api/V1/            13 thin controllers
│   ├── Middleware/
│   │   ├── EnsureUserIsActive.php     ← per-request; tokens outlive logins
│   │   └── ForceJsonResponse.php
│   ├── Requests/                      grouped by module
│   └── Resources/                     + Concerns/SerialisesEnums
│
├── Jobs/SendSmsJob.php
├── Models/                            12 models
├── Policies/                          10 policies
│
├── Repositories/
│   ├── Contracts/                     interfaces services depend on
│   └── Eloquent/                      implementations
│
├── Services/
│   ├── CustomerService.php            ← 3-table atomic create
│   ├── CustomerStatusService.php      ← the ONLY writer of customers.status
│   ├── SubscriptionService.php        ← due-date arithmetic, plan changes
│   ├── InvoiceService.php             ← the ONLY writer of invoice money
│   ├── PaymentService.php
│   ├── PlanService.php
│   ├── DashboardService.php
│   ├── Reports/{ReportService, ReportExporter}.php
│   └── Sms/
│       ├── SmsService.php             ← renders, logs, queues
│       ├── SmsGatewayManager.php      ← driver resolution
│       ├── SmsResult.php  GatewayStatus.php
│       ├── Contracts/SmsDriver.php
│       └── Drivers/{AndroidGateway, Twilio, Semaphore, Vonage, Log}Driver.php
│
├── Support/
│   ├── PhoneNumber.php                ← PH normalisation
│   └── ReferenceNumber.php            ← race-safe VTX-/INV-/RCP- sequences
│
└── Traits/LogsActivity.php            ← audit trail
```

---

## 3. Enums are the source of truth

An enum here is not just a type — it carries the behaviour that belongs to the value.

`CustomerStatus` owns the **legal transitions**:

```php
public function transitions(): array
{
    return match ($this) {
        self::Pending      => [self::Installed, self::Active, self::Disconnected],
        self::Active       => [self::Suspended, self::Disconnected],
        self::Suspended    => [self::Active, self::Disconnected],
        // A disconnected line requires a fresh installation to come back.
        self::Disconnected => [self::Pending],
        // …
    };
}
```

`CustomerStatusService` is the only writer of the column and consults this, so
"activate a disconnected line" cannot become reachable by accident.

Every enum also exposes `values()` (feeds `Rule::in()`), `options()` (feeds the React
selects) and `label()`/`color()` (travel with the data in API responses). One
declaration, so a new status can never be accepted by validation while staying
invisible in the UI.

`Permission` and `UserRole` drive `RolePermissionSeeder`, which *removes* permissions
no longer in the enum — see [04-RBAC.md](04-RBAC.md).

---

## 4. Services own the rules

### Transactions

`CustomerService::create()` writes to `customers`, `customer_connections` and
`subscriptions` in one transaction. A customer row with no connection and no
subscription is a half-encoded record that breaks every downstream report, so either
all three land or none do.

The account number is allocated **inside** that transaction, so the gap lock in
`ReferenceNumber` actually protects the sequence.

### Single writers

Two columns have exactly one thing in the codebase allowed to change them:

| Column(s) | Sole writer |
| --- | --- |
| `customers.status` | `CustomerStatusService` |
| `invoices.amount_paid` / `balance` / `status` | `InvoiceService` |

That is what makes the transition rules and the balance arithmetic actually hold.

### Money

All monetary arithmetic uses **bcmath on strings**:

```php
$amountDue = bcmul((string) $subscription->monthly_price, (string) $multiplier, 2);
$total     = bcadd(bcsub($amountDue, $discount, 2), $installationFee, 2);
```

`decimal(12,2)` in the database, never float. "Balance is 0.00000001 so the invoice
never closes" is a genuinely miserable bug to chase in production.

### Recompute, never increment

`InvoiceService::recalculate()` re-derives `amount_paid` from the payments on record
rather than adding to a running total. Increments drift the moment a payment is
voided or corrected, and this is the one table where drift is unacceptable.

Overpayment leaves a **negative** balance rather than being clamped — the credit is
real and someone has to decide what to do with it.

### Refusals are typed

`DomainException` is a business-rule violation: not a bug, not a validation error. It
renders itself as a 422 with a message the user can act on, and is excluded from
error reporting.

```php
throw DomainException::make(sprintf(
    '%d subscriber(s) are still on "%s". Move them to another plan first, or set this
     plan to Inactive to hide it from new signups.',
    $subscribers, $plan->name
));
```

---

## 5. Repositories

Services depend on `CustomerRepositoryInterface`, not on Eloquent. The payoff is
concrete rather than theoretical: `CustomerRepository::filtered()` builds the search
and filter query **once**, and the list endpoint, the CSV export and the
customer-master-list report all call it. An export therefore cannot drift from what
the table showed — which is the bug this layer exists to prevent.

Bound in `RepositoryServiceProvider`.

### Streaming

Exports and the nightly sweep use `cursor()`, not `get()` or `chunk()`:

```php
public function streamFiltered(CustomerFilters $filters): LazyCollection
{
    // cursor() holds one row in memory at a time. chunk() would re-run the query
    // per chunk and can skip rows when the sort column is not unique.
    return $this->filtered($filters)->cursor();
}
```

### Eager loading

`filtered()` loads `currentSubscription` and its `plan` **without a column list**, on
purpose. A restricted select that omits a column an API Resource reads produces a
null where an enum is expected, and `$subscription->status->value` then throws a 500
on a perfectly valid row. Both tables are narrow and a page is 15 rows — there is
nothing to win by trimming columns and a 500 to lose.

`SerialisesEnums` makes the resources null-safe anyway, so the mistake degrades to a
`null` in the payload rather than a broken response.

---

## 6. DTOs

A service that takes `array $data` cannot tell you what it needs, and every caller
ends up guessing key names. DTOs are `final readonly` with named constructors.

The `partial` flag handles PATCH: when set, `toArray()` drops nulls so a PATCH of one
field cannot blank the other twelve.

> **A trap worth knowing.** PHP's `+` array union keeps the **left** operand's value
> for a duplicate key. `$dto->toArray() + ['status' => Pending]` therefore yields
> `status => null`, because the DTO already has that key. This bit twice during
> development — once making a NOT NULL insert fail, once silently detaching every
> auto-allocated payment from its invoice. Services now assign those keys explicitly.

---

## 7. Normalisation at the boundary

`App\Support\PhoneNumber` accepts every format a PH encoder might type
(`0917 123 4567`, `9171234567`, `+63 917 123 4567`, `63917-123-4567`) and normalises
to E.164 only when handing a number to a gateway. The CRM keeps showing what the
encoder entered.

`App\Rules\PhilippineMobileNumber` delegates to the same class, so validation and
normalisation cannot disagree. A plain `digits:11` rule would reject
`+63 917 123 4567` — a perfectly valid way to write the same number — and accept
`01234567890`, which is not a number at all.

Also normalised on save: MAC to `AA:BB:CC:DD:EE:FF` (so a lookup matches whatever was
typed), serials uppercased (they are printed on stickers in caps), PPPoE usernames
lowercased.

### Reference numbers

`ReferenceNumber` generates `VTX-2026-00001` / `INV-…` / `RCP-…` with two layers of
defence, because `MAX(id) + 1` is a classic double-booking bug:

1. `lockForUpdate()` inside the transaction — on MySQL the `SELECT … FOR UPDATE`
   takes gap locks over the matched range, so a concurrent insert of the same prefix
   blocks until commit.
2. Retry on unique violation, bounded, and **only** when the violation names the
   sequence column — a duplicate `pppoe_username` must surface as a validation error,
   not be retried five times.

---

## 8. The SMS layer

`SmsDriver` is a five-method interface. Implementations **never throw for a delivery
failure** — they return a `SmsResult`, and the important field is `retryable`:

| Failure | Retryable | Why |
| --- | --- | --- |
| Timeout, socket error, 5xx, 429 | yes | The phone was asleep or the network blipped |
| 401/403, invalid recipient, 4xx | **no** | It will fail identically next time; retrying a rejected API key burns workers and, on a metered provider, money |

`SmsGatewayManager` resolves the driver, with **database settings winning over
`config/sms.php`** — so switching gateway or rotating a key needs no redeploy. Blank
settings do not shadow a working `.env` value, which is what a naive `array_merge`
would do.

The ordering decision that matters: **`SmsService` writes the log row before the send
is attempted.** If the gateway call created the row, a crashed worker or a hung phone
would leave no trace that the system ever tried to message that subscriber — and
"did the reminder go out?" is the one question this module exists to answer.

`SendSmsJob` takes an **id, not a model**. A serialised model that sits in a queue for
an hour is a stale snapshot; re-fetching means the job sees the row as it is now,
including a status another worker may already have set.

Full detail: [06-SMS-AUTOMATION.md](06-SMS-AUTOMATION.md).

---

## 9. Reports

Every method on `ReportService` returns the same envelope — `title`, `period`,
`columns`, `rows`, `totals`, `money_columns`, `summary`. One `ReportExport` renders
any of them to Excel or CSV and one Blade view renders any of them to PDF, without
knowing which report it was handed. Adding a report is a method plus a catalogue
entry, not a new exporter.

The three formats are genuinely different jobs, not one job with a flag: PDF needs a
laid-out Blade view, Excel needs typed cells so `SUM()` works (which is why the
currency symbol is **not** written into numeric cells — it would turn them into
text), and CSV streams so a 20,000-row export does not buffer in memory.

---

## 10. Scheduling

Defined in `bootstrap/app.php`. Order matters — you cannot text somebody about a bill
that does not exist yet:

```
00:01  vertex:generate-invoices     issue invoices, promote past-due to overdue
00:05  vertex:send-due-reminders    queue SMS for invoices due in exactly 3 days
01:00  vertex:suspend-overdue       opt-in
:00/:30 vertex:check-sms-gateway    cache health for the dashboard
Sun 02:00  vertex:prune-activity-logs
```

All are `withoutOverlapping()` and `onOneServer()` — the latter matters the moment
there is a second app server, or every subscriber gets texted twice.

Every command is **idempotent and re-runnable**. The reminder sweep's guard is
`invoices.reminder_sent`, set immediately on queueing rather than after delivery: the
guard has to hold even if the worker dies mid-batch, otherwise a re-run double-texts
everyone the first run reached.

`--dry-run` and `--date=` on the commands make the pipeline testable without waiting
for midnight.

---

## 11. Error handling

`bootstrap/app.php` maps every exception to a predictable JSON shape. Without this, an
unauthenticated API call tries to redirect to a `login` route that does not exist in
an API-only app and surfaces as a confusing 500.

| Exception | Response |
| --- | --- |
| `DomainException` | 422 with message (+ optional field errors) |
| `AuthenticationException` | 401 `{"message":"Unauthenticated."}` |
| `AuthorizationException` | 403 with the policy's own message where one was given |
| `ModelNotFoundException` | 404 `{"message":"Customer not found."}` |
| `ValidationException` | 422 `{message, errors}` |
| anything else (production) | 500 with a generic message; detail goes to the log, not the browser |
