# Vertex WiFi — SMS Automation

> Deliverables 10 & 11. The nightly reminder pipeline, all six drivers, and how to
> turn the phone you already own into a zero-cost gateway.
>
> **In a hurry?** You want the `device` driver and §6a Option A — Windows Phone Link.
> That is the route the Settings page walks you through on screen.

---

## 1. What the spec asked for

> Three days before Due Date, automatically send SMS reminder.
> Every midnight: check all customers. If Due Date is exactly 3 days away, queue an
> SMS job, send automatically, log every message.

That is implemented as a scheduled command → queue job → driver → log row. Each step
is separately testable, and the whole thing is re-runnable without double-sending.

---

## 2. The pipeline

```
  cron (every minute)
        │
        ▼
  php artisan schedule:run
        │
        ├── 00:01  vertex:generate-invoices
        │            issue invoices due inside the lead window,
        │            promote past-due invoices to `overdue`
        │
        └── 00:05  vertex:send-due-reminders
                     │
                     │  invoices WHERE status IN (unpaid, partially_paid, overdue)
                     │    AND due_date = today + 3
                     │    AND reminder_sent = false
                     │    AND customer.status IN (active, suspended)
                     ▼
              SmsService::queueDueReminder()
                     │
                     ├─► render the `due_reminder` template
                     └─► WRITE the sms_logs row (status: queued)   ◄── before sending
                     │
                     ▼
              invoices.reminder_sent = true          ◄── immediately, not after delivery
                     │
                     ▼
              is the active driver pull-based?   (SmsGatewayManager::isPullBased)
                     │
        ┌────────────┴─────────────┐
        │ yes — `device`           │ no — every other driver
        ▼                          ▼
  nothing is dispatched;    dispatch SendSmsJob(logId), staggered
  the row stays `queued`           │
  until the phone comes            ▼
  and collects it (§5a)     queue worker → SmsService::deliver()
                                   │
                                   ▼
                            SmsGatewayManager::driver()->send()
                                   │
                     ┌────────┬────┴─────┬─────────┬──────┐
                     ▼        ▼          ▼         ▼      ▼
                  android  twilio   semaphore   vonage   log
                     │        │          │         │      │
                     └────────┴─────┬────┴─────────┴──────┘
                                    ▼
                               SmsResult
                             ┌──────┴──────┐
                        successful      failed
                             │          ┌──┴───┐
                             ▼      retryable  permanent
                       markSent()      │          │
                                   release()   markFailed()
                                   w/ backoff  (no retry)
```

Order matters: invoices are generated **before** reminders, because you cannot text
somebody about a bill that does not exist yet.

The left branch is why a Phone Link setup needs **no queue worker running at
midnight** to hold messages safely — a queued row is durable state in `sms_logs`, not
a job waiting in memory.

---

## 3. Three design decisions

### The log row is written 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. "Did the reminder go
out?" is the one question this module exists to answer, so the attempt is recorded
first and the outcome is stamped onto it afterwards.

### `reminder_sent` is set on queueing, not on delivery

The idempotency guard has to hold even if the worker dies mid-batch. If it were set
after delivery, a re-run would double-text everyone the first run had already reached.
The trade-off is explicit: a message that fails permanently is not automatically
re-attempted by the next night's sweep — it shows as `failed` in the log with a
**Resend** button, which is the correct place for a human to decide.

### Retry is the driver's verdict, not a blanket `$tries`

```php
if (! $result->retryable) {
    // Marked failed by deliver(); returning normally keeps it off failed_jobs,
    // because there is nothing to replay.
    return;
}

$this->release($this->backoff()[$this->attempts() - 1] ?? 300);
```

| Failure | Retryable | Why |
| --- | --- | --- |
| Timeout / socket error | ✅ | The phone was asleep or the Wi-Fi blipped |
| HTTP 5xx | ✅ | Gateway-side fault |
| HTTP 429 | ✅ | Rate limit — wait and retry |
| HTTP 401 / 403 | ❌ | Wrong API key. It will fail identically in 5 minutes |
| HTTP 400 / 422 | ❌ | Malformed number or payload |
| Vonage status 4/6/etc. | ❌ | Rejected by the carrier |

Retrying a rejected API key three times burns queue workers and, on a metered
provider, money — while fixing nothing.

Backoff: `60s, 300s, 900s` (`SMS_RETRY_BACKOFF`).

---

## 4. Anti-footguns

**The default driver is `log`.** A fresh install has seeded subscribers with
placeholder numbers and a live scheduler from the first `queue:work`. Defaulting to a
real gateway would mean the first thing a new deployment does is text several hundred
strangers. The `log` driver writes the message to the Laravel log, reports success and
creates a real `sms_logs` row — so the whole SMS History screen can be exercised
end to end before a SIM is involved.

**Messages are staggered.** A phone acting as a gateway is rate-limited by the
carrier, and some carriers treat a burst as spam and cut the SIM off.
`sms.dispatch_delay_ms` (default 1500) spaces a batch out, capped so a large run still
finishes overnight.

**Only billable customers are chased.** The reminder query filters on
`customer.status IN (active, suspended)` — a pending or disconnected account should not
be asked for money.

**One bad row cannot abort the sweep.** A customer with no valid mobile number is
skipped and counted; an exception on one invoice is reported and the loop continues.

**Unknown template placeholders are rejected on save.** A typo like
`{customer_nmae}` passes every other check and then ships literally, in every
reminder, until somebody notices. Catching it at save time is the only place it is
cheap.

**Invoices are streamed.** `streamAwaitingReminder()` uses `cursor()`, so an ISP with
5,000 subscribers does not load every due invoice into memory to send a few hundred
texts.

---

## 5. Templates

Four are seeded, editable from **SMS → Templates** without a redeploy. The due-date
reminder is the exact wording from the specification:

```
Hello {customer_name},

This is a friendly reminder from {company}.

Your internet bill of {amount} is due on {due_date}.

Please settle your payment before the due date to avoid service interruption.

Thank you.

- {company}
```

| Key | When it fires |
| --- | --- |
| `due_reminder` | 3 days before an invoice due date (the nightly sweep) |
| `overdue_notice` | An invoice passes its due date unpaid |
| `welcome` | A subscriber is activated |
| `suspension_notice` | An account is suspended |

Placeholders: `{customer_name}` `{account_number}` `{amount}` `{due_date}` `{plan}`
`{invoice_number}` `{company}`.

`{amount}` is the invoice **balance**, not the original total — chasing the full amount
after a partial payment is both wrong and a support call. `{company}` comes from
Settings, so a rebrand does not touch code.

Placeholders with no matching value are left **in place** rather than blanked: a
visible `{amount}` in a test send is a far better signal than a silent gap in a message
that goes to 400 subscribers.

The **Preview** tab renders a template against a real subscriber, with character and
segment counts.

---

## 5a. Choosing a gateway — start here

### The short answer: the `device` driver

**If you want your own SIM sending the messages, pick `device` ("Your Phone").**
Everything on this page that mentions Phone Link, a gateway app, or iOS Shortcuts is
a *client* of that one driver — they differ only in what runs on the phone or PC.

> ### The one mistake to avoid
>
> Phone Link needs **SMS Driver = `Your Phone`**, *not* `Android app on the local
> network`. The names sound interchangeable and they are not:
>
> | | `device` — "Your Phone" | `android` — "local network" |
> | --- | --- | --- |
> | Who starts the conversation | The phone/PC asks the server | The server calls the phone |
> | Needs a static IP + open port | No | **Yes** |
> | Works with Phone Link | **Yes** | No |
> | Works with a gateway app on the handset | **Yes** | Yes |
>
> Choosing `android` for a Phone Link setup produces messages that sit in `queued`
> and then fail with a connection timeout, because the server is trying to open an
> HTTP connection to a phone that is not listening for one.

Every driver except `device` *pushes*: the server opens a connection to the gateway,
so the phone needs a static IP, an open port, and to be on a reachable network. In a
small ISP office that is precisely what breaks — a power cut changes the phone's DHCP
lease and every reminder silently fails.

The `device` driver inverts it. Nothing is pushed. Queued messages sit in
`sms_logs`, and the phone asks *"anything to send?"* on a timer, sends over its SIM,
and reports back:

```
  phone                              server
    │  POST /sms/device/pull            │
    │ ─────────────────────────────────►│   "here are 3 messages"
    │                                   │
    │  (sends each over its own SIM)    │
    │                                   │
    │  POST /sms/device/ack  {id,status}│
    │ ─────────────────────────────────►│   recorded in the SMS log
```

Consequences worth stating:

- **No static IP, no open port, no LAN.** Works over mobile data, from anywhere,
  behind any NAT.
- **Client-agnostic.** Anything that can make two HTTP calls can be the gateway:
  an Android automation app, iOS Shortcuts, a Raspberry Pi, a cron'd `curl`.
- **The server never blocks on the handset.** A phone that is asleep or out of
  signal *delays* messages instead of failing them.
- **At-least-once delivery.** A device can send and die before its ack arrives, and
  the lease expiry re-offers the message. For a billing reminder that is the right
  trade: a duplicate is an annoyance, a silently dropped reminder is an unpaid bill
  and a disconnection nobody saw coming.

### Three ways to be that phone

All three speak the same two endpoints with the same token. Pick on how much you want
to install and how unattended it has to be.

| | **A. Windows Phone Link** | **B. Gateway app on the handset** | **C. iPhone Shortcut** |
| --- | --- | --- | --- |
| Install on the phone | Nothing¹ | One app | Nothing |
| Needs a device token | **No** — sends from the server's own PC | Yes | Yes |
| Needs a PC left on | **Yes**, signed in | No | No |
| How often it runs | Every 5 min, from the scheduler | Every 1–5 min | **Once a day** |
| Survives a phone reboot | Re-pairs automatically | Yes | Yes |
| Prompts a human | No | No | ⚠️ Often — Apple gates *Send Message* |
| Disturbed by someone using the PC | **Yes** — it steals focus | No | No |
| Setup | §6a Option A | §6a Option B | §6a Option C |

¹ *Link to Windows* is built into most modern Android handsets; otherwise it is one
Play Store install.

**Recommendation.** If the phone is Android and you are happy to install one app, **B
is the sturdiest** — no PC, no focus stealing, survives reboots. Choose **A** if you
would rather not install anything on the handset, or the phone is already paired to
the PC. **C** is a fallback: it works, but once a day and usually with a tap.

### Why the iPhone route is only semi-reliable

The `device` driver is deliberately just two HTTP calls, so an iOS **Shortcut** can be
the gateway. Whether that is dependable enough for unattended billing reminders is a
separate question, and the honest answer is *not quite*:

| | Android | iPhone |
| --- | --- | --- |
| Make the two HTTP calls | ✅ | ✅ |
| Send an SMS from an automation | ✅ | ⚠️ Apple may prompt for confirmation |
| Trigger every N minutes | ✅ | ❌ Personal Automations have no interval trigger — only a fixed time of day |
| Run unattended, phone locked | ✅ | ⚠️ Unreliable |

So on an iPhone you get **one scheduled run a day**, and the *Send Message* step may
ask you to tap Send depending on your iOS version. That is workable for a nightly
reminder batch you glance at each morning; it is not "set it and forget it".

If you want genuinely unattended sending on your own SIM, put that SIM in **any cheap
Android handset** and use the same `device` driver — the server side is identical.

### The things iOS genuinely cannot do

Regardless of the above, these remain impossible and are not worth trying:

- Third-party apps **cannot send SMS silently**. `MFMessageComposeViewController`
  opens the Messages UI and requires a human to tap Send. There is no API for
  unattended sending, so no App Store equivalent of the Android gateway apps exists
  or can exist.
- Shortcuts has **no incoming-webhook trigger**. Personal Automations fire on time,
  location, alarms and so on — nothing can wake a Shortcut when the server has a
  message to send. A timed Shortcut that polls the API is technically buildable, but
  Apple gates the *Send Message* action behind a confirmation in most iOS versions,
  which defeats the point of an unattended 00:05 sweep.
- iOS does not expose the baseband, so the AT-command route used with USB GSM
  modems is unavailable.

> A Mac with **Text Message Forwarding** can script Messages over your iPhone's SIM
> via AppleScript. It genuinely works, but it needs a Mac left running, breaks on
> macOS updates, and recent macOS releases have tightened AppleScript access to
> Messages. There is deliberately no driver for it here — it is too fragile to sit
> under an automated billing reminder.

### Your own SIM, or a hosted provider?

| | **Your Phone** (`device`) | **Semaphore** (hosted) |
| --- | --- | --- |
| Extra hardware | The phone you already have | None |
| Whose SIM sends it | Yours | Theirs |
| Setup time | ~15 minutes | ~10 minutes |
| Cost shape | Your SIM's text allowance | Per message |
| Static IP / port forwarding | Not needed | N/A |
| Unattended on **Android** | ✅ | ✅ |
| Unattended on **iPhone** | ⚠️ once a day, may prompt | ✅ |
| Runs when the office is closed | While the phone (or PC) is on | Always |

**Rough sizing.** One reminder per subscriber per month:

- 200 subscribers → ~200 SMS/month. At Semaphore's order-of-magnitude rate (~₱0.50
  per message — **confirm current pricing**, it changes) that is roughly ₱100/month.
- On your own SIM with an unlimited-texts promo the marginal cost is zero, and a
  second-hand Android handset is a one-off few thousand pesos.

The crossover is usually a few hundred subscribers, so for a small ISP `device` wins
on cost. What you pay for it instead is *something to babysit*: a phone that must stay
charged and connected, or a PC that must stay signed in. A hosted provider has neither
failure mode.

**Recommendation.** Use `device` — your SIM, no monthly fee. Switch to Semaphore if
keeping a phone or PC alive turns out to be the unreliable part; it is a dropdown in
**Settings → SMS Gateway**, not a redeploy, and the templates, logs and schedule are
unchanged by the switch.

---

## 6. Drivers

Configured from **Settings → SMS Gateway**. Database settings win over
`config/sms.php` at runtime, so switching gateway or rotating a key needs no redeploy.
Secrets are encrypted at rest (`SettingType::Encrypted`) and **never** returned by the
API.

| `sms_driver` | Label in the UI | Push or pull |
| --- | --- | --- |
| `device` | Your Phone — Phone Link or a gateway app (recommended) | **pull** |
| `android` | Android app on the local network (advanced) | push |
| `twilio` | Twilio | push |
| `semaphore` | Semaphore (Philippines) | push |
| `vonage` | Vonage | push |
| `log` | Log only (nothing is sent) | — |

### The Settings page only shows the driver you picked

There are seventeen SMS settings across six drivers. Rendering them all at once is how
you end up pasting a Twilio SID into a form whose driver is `semaphore` and wondering
why nothing sends, so the panel filters itself:

- Each setting carries a **`driver`** field in the API payload, derived from its key
  prefix (`Setting::DRIVER_PREFIXES` — `sms_twilio_*` → `twilio`, and so on). Rows
  whose driver is not the selected one are hidden, with a count of what was hidden;
  driver-agnostic rows (`sms_enabled`, `sms_reminder_days_before`, `sms_sender_name`)
  always show, first and in that order.
- Each setting also carries **`is_managed`**. Six keys are written by the *app*, not
  by a person: `sms_device_last_seen`, `sms_device_last_name`, `sms_device_battery`,
  `sms_device_signal`, `sms_gateway_last_status`, `sms_gateway_last_sync`. They are
  never rendered as inputs — an editable "Device Battery %" box invites somebody to
  type a number that the next heartbeat silently overwrites. `UpdateSettingsRequest`
  also rejects them server-side with a 422, so the rule holds for API clients too:

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

  Those values are still *visible* — as the status chips and tiles on the gateway
  panel, which is the honest place for read-only state.

With `device` selected, that leaves six fields on screen instead of seventeen.

### `device` — your phone or PC collects the work

The pull driver, described in §5a. `DeviceGatewayDriver::send()` exists only to satisfy
the interface and returns a permanent failure by design; nothing ever calls it, because
`SmsService` skips job dispatch entirely for a pull driver. Its `status()` reads the
heartbeat the client last reported, which is where the battery and signal chips come
from.

Setup: §6a Options A/B/C.

### `android` — a gateway app the server calls (advanced)

Sends over the SIM's own unlimited-texts allowance instead of paying per message, like
`device` — but the server initiates the connection, which is the part that makes it
advanced rather than recommended. Prefer `device` with the same handset unless you
specifically need the server to push.

Targets the `capcom6/android-sms-gateway` REST API:

```
POST {url}/message   {"message": "...", "phoneNumbers": ["+639171234567"], "simNumber": 1}
GET  {url}/health
```

The send path, status path and auth mode are configurable, so the closely-related apps
(SMS Gateway API, Traccar SMS Gateway) work without a new driver. Both HTTP Basic and
API-key auth are supported, because the app has used both across versions.

**Setup**

1. Install the gateway app on a spare handset with an unlimited-texts SIM.
2. Give the phone a **static IP** on the office LAN. A DHCP lease change silently
   breaks every reminder — this is the most common failure in practice.
3. Set **Gateway URL** to `http://<ip>:<port>` (e.g. `http://192.168.1.50:8080`).
4. Paste the app's API key, or its username and password if it uses Basic auth.
5. Set **SMS Driver** to `android`.
6. Send a test from **SMS → Test & Diagnostics**.
7. Keep the handset on a charger.

Two practical notes baked into the driver:

- A **timeout is the expected failure**, not an exceptional one — the phone sleeps,
  drops off Wi-Fi, or changes IP. It maps to a retryable result rather than an
  exception.
- `/health` reports **battery level and signal strength**, which the dashboard shows —
  so a dying phone is visible before it stops sending. The parsing is defensive: the
  app's health payload keys and nesting have changed between releases, so a missing
  reading shows as "unknown" rather than breaking the page.

### Hosted providers

| Driver | Notes |
| --- | --- |
| **Semaphore** | Philippine provider, cheapest hosted option for PH numbers. Takes local-format MSISDNs (`09171234567`), so the recipient is converted back from E.164 on the way out. Health check reports remaining credits. |
| **Twilio** | Called over its REST API directly rather than through the SDK — one HTTP call does not justify the dependency. 429 is treated as retryable despite being a 4xx. |
| **Vonage** | Its quirk: a delivery failure arrives inside an **HTTP 200**. The `status` field on each message entry is the real outcome, so the body is inspected rather than the status code. `type: unicode` is set because the peso sign is not in the GSM alphabet. |
| **`log`** | Writes to the log, sends nothing. The default. |

Every driver is one `SmsDriver` implementation — 5 methods. Adding a provider is a new
class and one line in `SmsGatewayManager::make()`; adding its settings needs no UI work
at all, because the panel discovers them from the `sms_<driver>_` key prefix.

---

## 6a. Setup walkthrough

Everything below is done in the app at **SMS → Settings**, signed in as a Super
Admin. Nothing here needs a redeploy — settings are read at runtime.

| | |
| --- | --- |
| **Step 0** | Make the machinery run — everything needs this |
| **Step 1** | Look around on the `log` driver first |
| **Step 2** | Issue a device token — shared by A, B and C |
| **Option A** | Windows Phone Link *(nothing installed on the phone)* |
| **Option B** | A gateway app on the Android handset *(no PC)* |
| **Option C** | iPhone Shortcut *(once a day)* |
| **Step 3** | Go live |
| *or* | Semaphore / the `android` push driver — neither needs Step 2 |

### Step 0 — Make the machinery run

```bash
# Terminal 1 — the scheduler. Without it nothing is ever queued, on any driver.
cd backend
php artisan schedule:work
```

```bash
# Terminal 2 — the queue worker.
php artisan queue:work
```

> **The `device` driver does not need a queue worker.** `SendSmsJob` is the only
> queued job in the application, and a pull gateway never dispatches it — the message
> is durable state in `sms_logs` waiting for the phone. Run a worker anyway if you can
> (it costs nothing and makes switching drivers a one-field change), but if reminders
> are stuck on **Queued** under `device`, a missing worker is *not* the cause. Look at
> the bridge script or the phone instead.
>
> Every other driver does need it, and messages sit on **Queued** forever without one.

In production the scheduler is one crontab line instead — see
[13-DEPLOYMENT.md](13-DEPLOYMENT.md) §5:

```bash
* * * * * cd /path/to/backend && php artisan schedule:run >> /dev/null 2>&1
```

Confirm the schedule is registered:

```bash
php artisan schedule:list
```

You should see `vertex:generate-invoices` at 00:01 and `vertex:send-due-reminders`
at 00:05.

---

### Step 1 — Look around on the `log` driver first

The system ships on `log`: it renders and records every message but sends nothing.
Do this before touching a real gateway.

1. Go to **SMS → Test & Diagnostics**.
2. Enter your own mobile number and press **Send test**.
3. Go to **SMS → Message History** — the message is there, marked *Sent*, with the
   exact text.

Nothing left your machine. You have now confirmed the queue worker, the templates
and the log are all working, without texting anybody.

---

### Step 2 — Issue a device token

Shared by Options A, B and C below — they are three clients of one gateway, so the
server side is identical for all of them.

1. **Settings → SMS Gateway**, scroll to *Use your own phone and SIM*.
2. Name the device (just a label, so you can tell devices apart in the log) and press
   **Generate token**.
3. Copy the four values it shows — **token**, **pull URL**, **ack URL**, **ping
   URL**. The token is displayed once and never again; generating a new one revokes
   the old phone, which is also how you handle a lost handset.
4. Set **SMS Driver** to `Your Phone`, **SMS Enabled** on, and **Save**.

> The token carries only the `sms:device` ability. It can pull and acknowledge
> messages and nothing else — it cannot read subscribers, payments, or settings.
> Verified: a device token gets 403 on every other endpoint.

**Whatever holds the token must be able to reach the server**, and which machine that
is differs by option:

- **Option A (Phone Link):** the *PC* is the client, not the phone. If the script runs
  on the same machine as the server, `http://127.0.0.1:8000/api/v1` is correct — and
  it is what the Settings page's copy-ready command gives you. The phone never talks
  to the server at all; it only talks to Windows.
- **Options B and C:** the *phone* is the client. On the same Wi-Fi its LAN address is
  enough (`http://192.168.1.10:8000/api/v1`). To poll over mobile data the server must
  be reachable from outside — a domain, or a tunnel.

> Do not paste the address from your browser's URL bar. In development that is Vite on
> port **5173**, which proxies the API for the browser only; a script or phone has no
> such proxy and needs the API's own address, port **8000**.

---

#### The API, in three calls

Every request sends `Authorization: Bearer <token>`.

```http
POST {pull_url}
{ "device": "My Phone", "battery": 82, "limit": 10 }

→ { "messages": [ { "id": 41, "to": "+639171234567", "text": "Hello …" } ], "count": 1 }
```

Send each one over the SIM, then:

```http
POST {ack_url}
{ "id": 41, "status": "sent" }          // or {"status":"failed","error":"…"}
```

`GET {ping_url}` is a no-op health check for confirming the token works.

---

#### Option A — Windows Phone Link (nothing installed on the phone)

The Settings page walks you through this on screen — **Settings → SMS Gateway → Set up
Phone Link as the sender**. It is reproduced here so it is findable, and because steps
1 and 2 happen away from the PC.

**A1. Pair the phone with Windows (the QR code)**

1. On the PC, open **Phone Link** — install it from the Microsoft Store if it is
   missing — and choose **Android**. It displays a **QR code**.
2. On the phone, install **Link to Windows** from the Play Store. On most modern
   handsets it is already built in: search Settings for *Link to Windows* (Samsung
   puts it under *Connected devices*, Vivo/Oppo under *Connect & share*).
3. In that app, tap **Link your phone and PC** and **scan the QR code** on the PC
   screen.
4. Approve the pairing on both sides, then grant the permissions the phone asks for.

> **The Messages permission is the one that matters.** Phone Link will pair happily
> and still be unable to send a thing if SMS access was skipped. Grant **Contacts,
> Messages and Notifications**. To check afterwards: open Phone Link on the PC and
> click **Messages** — if you can see your existing conversations, the permission is
> there.

Confirm it works *before* involving this system: send yourself a text from the Phone
Link window by hand. If that fails, nothing below can succeed.

**A2. Stop the phone from killing the connection**

This is the step people skip, and then reminders stop a day later:

- On the phone, exclude **Link to Windows** from battery optimisation / allow it to
  run in the background.
- Keep the phone on the same Wi-Fi as the PC, and on a charger.
- Turn off any "clean up background apps" or aggressive power saver.

> **Vivo, Oppo, Xiaomi and Realme ROMs are especially aggressive** about closing
> background apps. If Phone Link sits on *Connecting…* or *Unable to connect*, this is
> almost always why — not the pairing.

**A3. Point this system at the phone**

1. **SMS Driver** → **Your Phone**. *Not* "Android app on the local network" — see the
   warning in §5a.
2. **SMS Enabled** → on.
3. Generate the **device token** (Step 2 above) if you have not already.
4. **Save**.

**A4. Let it run itself — no command, no token**

Run this once, from the project folder:

```powershell
.\tools\install-autosend.ps1
```

That registers a single scheduled task — `php artisan schedule:work`, started at
sign-in — which then drives everything on its own clock: invoices at 00:01,
reminders at 00:05, and `vertex:phone-link-send` every five minutes to drain the
outbox through Phone Link. There is nothing left to run by hand.

Check the chain without texting anybody:

```powershell
cd backend
php artisan vertex:phone-link-send --probe
```

It reports the settings half (driver, master switch, opt-in) and the machine half
(Phone Link running, connected, controls present) separately, because they fail for
unrelated reasons.

> **Why Phone Link needs no device token.** The token authenticates a *phone*
> reaching the server across a network. Phone Link's sender is, by definition, the
> Windows PC — and in a single-PC install that is the machine running the server.
> `vertex:phone-link-send` therefore reads its own database and shells out to
> PowerShell only to drive the window. Same outbox, same pickup lease, same SMS log;
> one less credential to leak. Issue a device token only for Options B and C.

**Requirements, stated plainly:**

- **You must stay signed in.** UI Automation needs a real desktop. A locked screen is
  fine; signing out is not. This is why `install-autosend.ps1` registers an
  *interactive* task and will not offer "run whether the user is logged on or not" —
  that runs in session 0, where there is no window to drive and every message fails.
- It takes keyboard focus for a second or two per message.
- `vertex:phone-link-send` no-ops unless the driver is `device` **and**
  `sms_device_auto_send` is on, so leaving it scheduled costs nothing on an install
  that uses a hosted provider.

Watch it work:

```powershell
Get-Content backend\storage\logs\phone-link.log -Wait
```

Turn it off again with `.\tools\install-autosend.ps1 -Remove`.

**A4 (alternative) — run the bridge script by hand**

Use this when the PC driving Phone Link is *not* the machine running the server; it
talks to the API over HTTP and therefore does need a device token.
`tools/phone-link-gateway.ps1` implements the same pull/send/ack loop and does the
sending by driving the Phone Link window.

```powershell
# 1. Check Phone Link is reachable and the controls are where we expect.
.\tools\phone-link-gateway.ps1 -Diagnose

# 2. Prove the token and the queue without sending anything.
.\tools\phone-link-gateway.ps1 -ApiUrl http://127.0.0.1:8000/api/v1 -Token '12|abc…' -DryRun -Once

# 3. Go live.
.\tools\phone-link-gateway.ps1 -ApiUrl http://127.0.0.1:8000/api/v1 -Token '12|abc…'
```

Run it on a schedule with Task Scheduler (`-Once` per run):

```
Program:   powershell.exe
Arguments: -NoProfile -ExecutionPolicy Bypass -File "D:\VertexFiberSystem\tools\phone-link-gateway.ps1" -ApiUrl "http://127.0.0.1:8000/api/v1" -Token "12|abc…" -Once
Trigger:   Daily, repeat every 5 minutes
Settings:  Run only when user is logged on   ← required; UI automation needs a desktop
```

**How it drives Phone Link.** Microsoft publishes no API or SDK for Phone Link, so
UI Automation is the only route. The script uses stable AutomationIds rather than
screen coordinates, which makes it far sturdier than a click-recorder:

| Control | AutomationId |
| --- | --- |
| New message | `NewMessageButton` |
| Recipient | an `Edit` named **`To`** — matched by *name*, because its id (`TextBox`) is shared with the search box |
| Message body | `InputTextBox` |
| Send | `SendMessageButton` |

Two details that make it work rather than half-work:

- Text is set via `ValuePattern`, then nudged with one space + backspace. WinUI
  sometimes does not raise `TextChanged` for a programmatic `SetValue`, leaving the
  Send button disabled with the text visibly sitting there.
- **A disabled Send button is treated as a failure, never clicked through.** If the
  recipient did not resolve or the phone is offline, the message is acked as
  `failed` with the reason instead of being reported as sent.

**What it costs you.** This is desktop automation, so:

- The PC must be **awake, signed in and unlocked** — a locked session has no UI.
- Phone Link must be running and **actually connected**. The script checks this and
  refuses to send otherwise (verified: with the phone disconnected it acks
  `failed — Phone Link is not connected to the phone.` and never touches the window).
- It **takes keyboard focus** for a second or two per message. Do not type on the PC
  while a batch runs.
- A Phone Link update could rename a control. `-Diagnose` prints the live tree.

Worth being blunt about the trade: a gateway app on the same Android phone (Option B)
needs no PC at all, survives reboots, and cannot be disturbed by someone using the
computer. Phone Link is the right choice when you would rather not install anything on
the handset, or the pairing already exists.

---

#### Option B — a gateway app on the Android handset (no PC)

Same three calls, but Android automation apps can run on an interval and send SMS
without a prompt. Any of these work — no coding:

- **HTTP Shortcuts** (free) — has a built-in scheduler and a scripting step.
- **Automate** or **MacroDroid** — flowchart builders with HTTP + Send SMS blocks.
- **Tasker** — the most capable, paid.

Build one task:

1. **HTTP Request** → `POST` your **pull URL**, header
   `Authorization: Bearer <token>`, body `{"device":"Gateway Phone"}`.
2. **For each** item in `messages`: **Send SMS** to `to` with body `text`.
3. **HTTP Request** → `POST` your **ack URL** with `{"id": <id>, "status": "sent"}`.
4. Trigger it every 1–5 minutes.

Grant the app SMS permission, exclude it from battery optimisation, and leave the
phone on a charger. Driver stays **`device`** — this is the same gateway, a different
client.

---

#### Option C — iPhone (Shortcuts), one run per day

1. **Shortcuts → new shortcut**, name it `Send Vertex SMS`.
2. **Get Contents of URL**
   - URL: your **pull URL**
   - Method: `POST`
   - Headers: `Authorization` = `Bearer <your token>`
   - Request Body: JSON → `device` (Text) = `My iPhone`
3. **Get Dictionary Value** → key `messages` → from the previous result.
4. **Repeat with Each** over that value. Inside the loop:
   - **Get Dictionary Value** `to` from *Repeat Item* → **Send Message**
     (Recipients = that value, Message = *Get Dictionary Value* `text`).
   - **Get Contents of URL** → your **ack URL**, `POST`, same `Authorization`
     header, JSON body: `id` (Number) = *Get Dictionary Value* `id`,
     `status` (Text) = `sent`.
5. **Automation → Personal Automation → Time of Day**, pick a time (e.g. 09:00),
   run `Send Vertex SMS`, and turn **Ask Before Running** off.

**Expect this:** Apple gates *Send Message* in automations, so depending on your iOS
version you may still get a confirmation prompt. It also only runs once per day.
That is fine for a nightly reminder batch you glance at each morning; it is not
suitable if you need messages to go out within minutes. See §5a for why.

Test the pieces before trusting it: run the shortcut manually with one message
queued, and watch it appear as **Sent** in **SMS → Message History**.

---

#### Anything else

The contract is plain HTTP, so a Raspberry Pi with a GSM hat, a spare laptop with a
USB modem, or a cron'd shell script are all equally valid gateways. Two calls, one
header.

---

### Not using your own phone? Two other routes

Both replace Step 2 and the Options above entirely — no device token, no bridge script.

#### Semaphore (hosted; no phone involved)

1. **Create an account** at semaphore.co and top it up.
2. **Copy your API key** from their dashboard.
3. *(Optional)* Register a **Sender Name** with them — e.g. `VertexWiFi`. Until it is
   approved, messages send from their shared sender ID. This is a Semaphore-side
   approval, not something this app controls.
4. In **SMS → Settings**:
   - **SMS Driver** → `Semaphore (Philippines)`. Set this **first** — the panel then
     shows the Semaphore fields and hides everyone else's.
   - **Semaphore API Key** → paste the key. It is encrypted at rest and never sent
     back to the browser.
   - **Sender Name** → your approved name, or leave blank.
   - **SMS Enabled** → on.
   - Press **Save SMS Gateway**.
5. Go to **Test & Diagnostics**, send a test to your own phone. It runs
   synchronously, so you get the real result immediately — including the reason if
   it fails.
6. The **gateway strip** at the top of the SMS page now shows your remaining
   credits.

Numbers are handled for you: the app stores what the encoder typed, normalises to
`+63…` internally, and converts back to `09…` for Semaphore, which wants the local
format.

#### The `android` push driver (advanced)

A gateway app on a handset the **server calls**, rather than one that calls the server.
Prefer Option B above unless you specifically need the push direction — this is the
setup that breaks when the phone's IP changes.

1. **Install a gateway app.** This driver is written against
   [`capcom6/android-sms-gateway`](https://github.com/capcom6/android-sms-gateway)
   (free, open source). Install it and start the local server in the app.
2. **Give the phone a static IP.** Do this in your router's DHCP reservation, not
   just in Android's Wi-Fi settings.

   > This is the single most common failure in practice. On DHCP the phone's IP
   > changes after a power cut, and every reminder silently fails until somebody
   > notices.

3. **Note the address and credentials** the app displays — typically
   `http://192.168.1.50:8080` plus either an API key or a username/password pair.
4. In **SMS → Settings**:
   - **SMS Driver** → `Android app on the local network (advanced)`. Set this first so
     the Android fields appear.
   - **Android Gateway URL** → `http://192.168.1.50:8080` (scheme and port included).
   - **Android Gateway API Key** → paste it. *Or*, if your app version uses HTTP
     Basic auth, fill **Username** and **Password** instead and leave the key blank.
   - **SIM Slot** → `1`, or `2` on a dual-SIM handset if the texting SIM is in the
     second tray.
   - Press **Save SMS Gateway**.
5. Go to **Test & Diagnostics** and send a test.
6. The gateway strip and the dashboard now show the phone's **battery level and
   signal strength**, so a dying handset is visible before it stops sending.

**Keep the phone on a charger**, exclude the gateway app from Android's battery
optimisation, and make sure the server can reach the phone (same LAN or a route
between them).

---

### Step 3 — Go live

1. Set **Reminder Days Before Due Date** (3 by default — the specification's value).
2. Check the wording at **SMS → Templates**. The **Preview** tab renders it against a
   real subscriber so you can see the actual amount and date.
3. Dry-run tonight's sweep without sending anything:

   ```bash
   php artisan vertex:send-due-reminders --dry-run
   ```

4. When you are happy, leave the queue worker and cron running. From then on it is
   automatic, and every message is recorded in **Message History**.

---

## 7. The log

Every attempt writes a row: recipient (E.164), the **rendered** message verbatim,
driver used, type, status, gateway response, gateway message id, attempt count,
timestamps and any error.

The rendered body is stored rather than re-derived from the template, because the
template may be edited afterwards — and the log has to show what was actually sent.

```
queued ──► sent ──► delivered
       └──► failed
```

`sent` means the gateway accepted it. `delivered` means the handset confirmed it — only
drivers that report delivery receipts ever reach that state, so the UI treats `sent`
as the normal terminal state.

Not audited by `LogsActivity`: this table *is* the audit trail for SMS. `SmsLogPolicy`
returns `false` for every write method — it is append-only evidence.

---

## 8. Testing without waiting for midnight

```bash
# What would go out, changing nothing
php artisan vertex:send-due-reminders --dry-run

# Pretend today is a specific date
php artisan vertex:send-due-reminders --date=2026-03-13

# Override the lead time
php artisan vertex:send-due-reminders --days=7

# Drain the queue
php artisan queue:work --stop-when-empty

# Poll the gateway (non-zero exit when offline, so it can drive an alert)
php artisan vertex:check-sms-gateway
```

A verified run against the seeded data:

```
$ php artisan vertex:send-due-reminders --date=2026-03-13
INFO  Reminders for invoices due 2026-03-16 (3 day(s) ahead of 2026-03-13).
  queued  INV-2026-00030 / Jennifer Rose Padilla -> +639321234516
  Queued 1   Skipped 0   Failed 0

$ php artisan vertex:send-due-reminders --date=2026-03-13      # immediately again
  Queued 0   Skipped 0   Failed 0
  INFO  No invoices are due on that date.                      ← idempotent

$ php artisan queue:work --stop-when-empty
  App\Jobs\SendSmsJob ......................... 29.08ms DONE

$ # resulting log row
  reminder  sent  +639321234516  attempts=1
```

And the message it produced:

```
Hello Jennifer Rose Padilla,

This is a friendly reminder from Vertex WiFi.

Your internet bill of ₱1,499.50 is due on March 16, 2026.

Please settle your payment before the due date to avoid service interruption.

Thank you.

- Vertex WiFi
```

---

## 9. Configuration reference

Driver-agnostic — always shown in the panel, in this order:

| Setting (UI) | `.env` fallback | Default |
| --- | --- | --- |
| SMS Driver | `SMS_DRIVER` | `log` |
| SMS Enabled | `SMS_ENABLED` | `true` |
| Reminder Days Before | `SMS_REMINDER_DAYS_BEFORE` | `3` |
| Sender Name | `SMS_SENDER_NAME` | `Vertex WiFi` |

Per-driver — shown only when that driver is selected. The `sms_<driver>_` key prefix is
what scopes them (`Setting::DRIVER_PREFIXES`):

| Setting (UI) | `.env` fallback | Default |
| --- | --- | --- |
| Send automatically via Phone Link | — *(database only)* | `false` |
| Device Pickup Lease (minutes) | — *(database only)* | `5` |
| Android Gateway URL | `SMS_ANDROID_URL` | — |
| Android API Key 🔒 | `SMS_ANDROID_API_KEY` | — |
| Android Username | `SMS_ANDROID_USERNAME` | — |
| Android Password 🔒 | `SMS_ANDROID_PASSWORD` | — |
| SIM Slot | `SMS_ANDROID_SIM_SLOT` | `1` |
| Twilio SID / Token 🔒 / From | `TWILIO_*` | — |
| Semaphore API Key 🔒 | `SEMAPHORE_API_KEY` | — |
| Vonage Key / Secret 🔒 | `VONAGE_*` | — |
No UI, `.env` only:

| `.env` | Default |
| --- | --- |
| `SMS_DISPATCH_DELAY_MS` | `1500` |
| `SMS_MAX_ATTEMPTS` | `3` |
| `SMS_RETRY_BACKOFF` | `60,300,900` |
| `SMS_DAILY_DISPATCH_TIME` | `00:05` |

🔒 = encrypted at rest, never returned by the API.

**Written by the app, never editable** (`Setting::MANAGED_KEYS`). They appear in the
`GET /settings` payload with `is_managed: true`, are not rendered as inputs, and a
`PUT` that includes one is rejected with 422:

| Key | Written by |
| --- | --- |
| `sms_device_last_seen` | every device pull/ping |
| `sms_device_last_name` | every device pull/ping |
| `sms_device_battery` | the device, if its client reports it |
| `sms_device_signal` | the device, if its client reports it |
| `sms_gateway_last_status` | `vertex:check-sms-gateway` |
| `sms_gateway_last_sync` | `vertex:check-sms-gateway` |

---

## 10. Troubleshooting

### Phone Link / `device` driver

| Symptom | Cause |
| --- | --- |
| Messages fail with a **connection timeout** on a Phone Link setup | Driver is set to `android`, not `device`. The server is trying to call the phone. See §5a |
| Everything stuck on **Queued**, `device` driver | Expected until the phone or bridge script polls. **Not** a queue-worker problem — a pull gateway dispatches no job. Check the script is running and the token is right |
| Rows stuck on **Queued** but stamped `log` (or another push driver) | They were queued *before* you switched to `device`, so a `SendSmsJob` is still sitting in the queue. Harmless: `deliver()` detects the pull gateway, re-stamps the row to `device` and leaves it queued for the phone. Run `php artisan queue:work --stop-when-empty` once to clear the stale jobs |
| A queued message does not send for a minute or two | `sms.dispatch_delay_ms` (1500ms) staggers a batch by the number already queued today, so jobs are dispatched with a delay. `queue:work --stop-when-empty` can exit *before* a delayed job is runnable — run it again |
| Phone Link sits on *Connecting…* / *Unable to connect* | The phone's ROM killed *Link to Windows*. Exclude it from battery optimisation (§6a A2). Vivo/Oppo/Xiaomi/Realme especially |
| Paired fine, but sends fail | The **Messages** permission was skipped. Open Phone Link → Messages; if your conversations are missing, that is it |
| Bridge script: `Phone Link is not connected to the phone.` | Working as intended — it refuses to send rather than acking a message it never delivered. Fix the pairing first |
| Bridge script sends nothing and reports no error | PC locked or signed out. UI automation needs a live desktop — Task Scheduler must use *Run only when user is logged on* |
| Automatic sending does nothing at all | Run `php artisan vertex:phone-link-send --probe`. It names which half is wrong: settings (driver / SMS enabled / auto-send opt-in) or machine (Phone Link running, connected, controls present) |
| Automatic sending stopped after a reboot | The scheduled task starts **at sign-in**. Until somebody logs on, nothing runs — that is inherent to desktop automation, not a bug. A gateway app on the handset (Option B) has no such dependency |
| Dry run listed messages, then the live run said **"Nothing to send"** | The dry run used to *claim* the batch, holding it for the pickup lease. Fixed: `-DryRun` now peeks without claiming. On an older copy, wait out the lease (5 min) and re-run |
| Script says "Nothing to send" but the UI shows messages waiting | A recent pull holds them under its lease. The script now reports this explicitly (`held`). They are re-offered when the lease expires |
| A control could not be found | A Phone Link update renamed it. Run `-Diagnose` to print the live tree |
| Same subscriber texted twice | A device sent then died before its ack; the lease re-offered the message. Deliberate — see §5a "at-least-once" |
| Device chip shows online, "Token last used: Never" | Different facts. The chip is the last *heartbeat*; the tile is the last use of the *current* token. Replacing a token resets the tile, not the heartbeat |
| Can't edit Device Battery / Last Check-in | Correct — they are app-written (§9). Read them from the status chips |
| **Resend** on a failed message | Re-stamps the row to the *currently active* driver and clears its pickup lease, so it becomes collectable again on the next poll. It does not dispatch a job under `device` |

### Everything else

| Symptom | Cause |
| --- | --- |
| Everything stuck on **Queued** (push drivers) | No queue worker. `php artisan queue:work` |
| Nothing queued at all | Cron not installed, or no invoice falls due in exactly N days. Try `--dry-run --date=` |
| "SMS is disabled in settings" | `sms_enabled` is off |
| Can I use my iPhone? | Yes, but only ~once a day and it may prompt — §5a. For unattended sending use Android or a hosted provider |
| A driver's fields are missing from Settings | The panel only shows the selected driver's fields. Change **SMS Driver** first, then fill them in |
| `android` driver times out | Phone asleep, off the LAN, or its DHCP lease changed — give it a static IP, or move to `device` and skip the problem |
| `HTTP 401` from the gateway | Wrong API key, or the app expects Basic auth (fill username/password instead) |
| Messages sent but never `delivered` | Normal — only some drivers report delivery receipts. `sent` is the terminal state |
| Peso sign shows as `?` | Only affects providers without unicode support; the Vonage driver sets `type=unicode` explicitly |
| Reminder wording did not update | The stored `message` is captured at send time; editing a template affects future sends only |
| Same subscriber texted twice | Two schedulers on two servers. Keep `onOneServer()` |
| A message failed permanently and never retried | By design — `reminder_sent` is set on queueing (§3). Use **Resend** |
