# SMS (Shared · `Src\Common\Sms`)

**Provider:** SMS360 / Bulk360 · **Interface:** `Src\Common\Sms\SmsSender` · **Bound in:** [`AppServiceProvider`](/app/Providers/AppServiceProvider.php)

## What it does

Sends a plain text SMS to one number. It is the only sanctioned way to send an SMS: every
feature depends on the **`SmsSender` interface**, never on the provider, so the gateway can be
swapped (Twilio, another aggregator) by changing one binding — no caller changes.

Today it carries the **sign-in codes**: the passwordless login OTP, the `/register` phone
verification, and the profile phone change / verify flows. It is deliberately generic — any
future feature (appointment reminders, payment notices) can use it.

## How it works

- **Contract** — [`SmsSender::send(string $to, string $text): bool`](/src/Common/Sms/SmsSender.php).
  `true` = the gateway accepted the message; `false` = it did not (never throws, so a failed SMS
  can't 500 the request that triggered it).
- **Implementation** — [`Sms360Sender`](/src/Common/Sms/Sms360Sender.php) POSTs form-urlencoded
  `user` / `pass` / `to` / `text` to the SMS360 endpoint and treats the JSON `code === 200` as
  accepted. The number is sent as **bare digits** (a leading `+` is stripped — the gateway
  rejects it).
- **Failure is logged, not thrown.** A rejection logs a warning with the masked number, the HTTP
  status, the gateway `code`, **and the gateway's own `desc`** — so a refusal explains itself in
  the log without anyone re-running it by hand.
- **Not configured = no send.** With no credentials it logs and returns `false` rather than
  attempting a call, so a fresh environment fails quietly instead of erroring.
- **Credentials are DB-first.** `services.sms360.*` is overridden at boot by
  [Messaging credentials](/docs/modules_handbook/shared/messaging-credentials/readMe.md)
  (Messages → Settings → Delivery APIs) when a value is saved there, falling back to `.env`.
  `Sms360Sender` still just reads `config(...)` — nothing to change in consumers.
- **Synchronous.** `send()` blocks until the gateway answers. Fine for one code; **bulk sending
  must be queued** (see the WhatsApp broadcast engine for the pacing pattern) — never loop
  `send()` inside a controller.

### Reference usage — the passwordless phone code (reference implementation)

The canonical consumer is [`PasswordlessAuth::sendPhoneOtp()`](/app/Services/Auth/PasswordlessAuth.php),
used by sign-in, `/register`, and the profile phone flows. Copy this shape:

1. Generate the code and cache it **before** sending, keyed by an opaque challenge id — so the
   value being proven lives server-side and the client can never swap it.
2. Try the richer channel first (a WhatsApp authentication template when
   `WhatsappTemplate::otpLoginReady()` holds), and **fall back to `SmsSender` silently** — the
   user must get a code regardless of which channel is healthy.
3. Call `app(SmsSender::class)->send($digits, $text)` and **ignore the boolean for the response**:
   the caller's answer stays identical whether or not the send worked, so it never becomes an
   oracle for "does this number have an account".
4. Rate-limit the route that triggers it (`throttle:6,1`) — **every call costs money**, and an
   unthrottled send endpoint is an SMS-bill attack.

```php
// Anywhere a feature needs an SMS:
app(\Src\Common\Sms\SmsSender::class)->send('60123456789', 'Your appointment is tomorrow at 3pm.');
```

In tests, bind a mock instead — never send for real:

```php
$sms = Mockery::mock(SmsSender::class);
$sms->shouldReceive('send')->once()->andReturnTrue();
$this->app->instance(SmsSender::class, $sms);
```

## Configuration

`config/services.php` → `sms360`, from `.env`:

| Key | Env | Notes |
| --- | --- | --- |
| `user` | `SMS_API_USER` | Gateway account |
| `pass` | `SMS_API_PASS` | Gateway password — **never commit** |
| `url` | `SMS_API_URL` | Optional; the config default is the live endpoint, so `.env` normally omits it |
| `ca_bundle` | — | CA bundle for outbound TLS (the local cURL-77 fix) |

### ⚠️ The gateway is IP-whitelisted

SMS360 refuses any call from an un-whitelisted source with
`code 403 — "Message API not enabled, requested IP not whitelisted or not enabled"`. **Each
environment's public IP must be added** in the SMS360 portal (Account → System Configurations →
Whitelist IPs).

- **Production** is a static server IP → whitelist once, done.
- **Local dev is the trap**: a home broadband IP **rotates**, so SMS silently starts 403-ing again
  with no code change. Do not chase it — the `/register` page shows the codes on screen in the
  `local` environment (see [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)),
  so the whole flow is testable with no working gateway.
- Diagnose with the logged `desc` first; if it says the IP is not whitelisted, check the machine's
  **current** IPv4 (`curl -4 ifconfig.me`) against the portal list before touching any code.

## GUIDELINES alignment

- **Interface + binding** (not a facade over a concrete class) so the provider is swappable and
  tests can `instance()` a mock.
- **Never throws** — an SMS is a side effect of a request, not its purpose.
- **Secrets only in `.env`**; the sender logs a **masked** number and never the credentials.

## Related files

**Backend**
- [src/Common/Sms/SmsSender.php](/src/Common/Sms/SmsSender.php) — the interface every caller depends on.
- [src/Common/Sms/Sms360Sender.php](/src/Common/Sms/Sms360Sender.php) — the SMS360 implementation (form POST, `code === 200`, masked logging, `desc` on rejection).
- [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) — binds `SmsSender` → `Sms360Sender`.
- [app/Services/Auth/PasswordlessAuth.php](/app/Services/Auth/PasswordlessAuth.php) — the reference consumer (`sendPhoneOtp`, WhatsApp-first with SMS fallback).
- [app/Http/Controllers/Concerns/HandlesProfile.php](/app/Http/Controllers/Concerns/HandlesProfile.php) — profile phone change / verify, via `PasswordlessAuth`.

**Config**
- [config/services.php](/config/services.php) — the `sms360` block.

**Tests**
- [tests/Feature/Common/Sms360SenderTest.php](/tests/Feature/Common/Sms360SenderTest.php) — accepts `code 200`, treats other codes as failure, sends nothing when unconfigured.

**Routes** — none of its own. It is a service; the routes that spend it are the auth + profile ones (all `throttle:6,1`).

## Related modules

- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — passwordless sign-in + the contact-verification gate this service delivers codes for.
- [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) — the richer channel tried before falling back here, and the queue/pacing pattern to copy for bulk sending.
