# Twilio Voice Route Map

Quick reference for **local URLs** (and the files that implement them). Use this when wiring Twilio Console webhooks, testing with ngrok, or tracing voice/SMS/billing flows.

**Related docs**

| Topic                         | Where |
|------------------------------|-------|
| Stripe (wallet, webhooks)    | [`docs/RINGVOO_SYSTEM_NOTES.md`](docs/RINGVOO_SYSTEM_NOTES.md#stripe-wallet-funding) — wallet funding, **receipt copy**, **admin refunds** ([card refunds section](docs/RINGVOO_SYSTEM_NOTES.md#stripe-card-refunds-super-admin-api--webhooks)) |
| SMS (pricing, billing)       | [`docs/RINGVOO_SYSTEM_NOTES.md`](docs/RINGVOO_SYSTEM_NOTES.md#sms-twilio-messaging) — *SMS (Twilio Messaging)* |

---

## Conventions

| Term            | Meaning |
|----------------|---------|
| **Base URL**    | Local dev: `http://localhost:3035` (match your `NEXTAUTH_URL` / dev port). Production: public origin from `SERVER_URL` / your domain — **no trailing slash**. |
| **Path**        | App Router route; prepend base for a full URL (e.g. base + `/api/twilio/voice/inbound`). |
| **Twilio URLs** | Twilio Console must use **HTTPS** public URLs; local dev typically uses ngrok (or similar) and the same path suffixes below. |

---

## Twilio Voice — SDK, TwiML, status & recovery

| Method | Path                                   | Purpose |
|--------|----------------------------------------|---------|
| `GET`  | `/api/twilio/token`                    | Session-scoped Twilio **Access Token** for the Voice SDK (`Twilio.Device`). Identity from session only. |
| `POST` | `/api/twilio/voice`                    | **TwiML App compatibility**: delegates to outbound handler (same as `/api/twilio/voice/outbound`). |
| `POST` | `/api/twilio/voice/outbound`           | Twilio **Voice webhook** — outbound TwiML; insufficient balance message / hang-up; `<Dial timeLimit=…>` from max-duration logic. |
| `POST` | `/api/twilio/voice/inbound`            | Twilio **Voice webhook** — PSTN → browser `Client`; resolves `TwilioNumber` → user; **wallet gate** + `<Dial timeLimit>` + optional inbound low-balance prompt audio. |
| `GET`  | `/api/twilio/voice/max-duration-preview` | Logged-in **preview** of max-duration decision (query: `iso`, e.g. `?iso=US`). Returns retail rates, `safeMaxSeconds`, `insufficient`. |
| `POST` | `/api/twilio/call-status`              | Twilio **status callback** — call lifecycle, settlement. |
| `POST` | `/api/twilio/recording-status`         | Twilio **recording** status webhook (validated signature; recording metadata). |
| `POST` | `/api/twilio/client-call-metrics`      | Browser **fallback** timing + provisional `Call` rows when callbacks lag. |
| `GET`/`POST` | `/api/twilio/sync-cost-jobs`       | Run due **delayed cost-sync** jobs (`?limit=50`). If `CRON_SECRET` is set: `Authorization: Bearer <CRON_SECRET>`. |
| `GET`/`POST` | `/api/twilio/refresh-voice-pricing-csv` | Rebuild **`data/OutboundVoicePricing.csv`** from [Twilio Pricing API v2 Voice Countries](https://www.twilio.com/docs/voice/pricing-api/voicecountry-resource) (`pricing.twilio.com/v2`). Same Bearer / dev rules as `sync-cost-jobs`. Local script: `npm run refresh-outbound-voice-pricing-csv`. **Vercel cron:** `vercel.json` → `/api/twilio/refresh-voice-pricing-csv` (weekly). |
| `GET`/`POST` | `/api/twilio/sync-calls`           | **Recovery**: pull Twilio calls, upsert `Call`, settle when priced (`?days=1&limit=200&refreshDetails=true&pendingOnly=false`). Session, or `TWILIO_SYNC_CALLS_SECRET` / `CRON_SECRET` as Bearer. |
| `GET`  | `/api/twilio/call-holds`               | Inspect **call holds** for current user (admins: `?userId=&status=`). Example: `?status=PENDING`. |
| `POST` | `/api/twilio/outgoing-caller-id/validation-status` | Twilio **Validation Request** status callback — registers Verified Caller ID on success (`VerificationStatus`). |
| `POST` | `/api/twilio/custom-caller-id/voice`   | *(Legacy / unused)* Custom TwiML verification — replaced by Twilio `validationRequests` API. |
| `POST` | `/api/twilio/custom-caller-id/confirm` | *(Legacy / unused)* |
| `POST` | `/api/twilio/custom-caller-id/status`  | *(Legacy / unused)* Optional cost logs if old flow were used. |
| `GET`/`POST` | `/api/twilio/call-holds/cleanup`   | Expire stale **pending** holds (`?limit=500`). If `CRON_SECRET` is set: Bearer auth. |

**Example (local)**

```text
http://localhost:3035/api/twilio/voice/max-duration-preview?iso=US
http://localhost:3035/api/twilio/sync-cost-jobs?limit=50
http://localhost:3035/api/twilio/refresh-voice-pricing-csv
http://localhost:3035/api/twilio/sync-calls?days=1&limit=200&refreshDetails=true&pendingOnly=false
http://localhost:3035/api/twilio/call-holds?status=PENDING
http://localhost:3035/api/twilio/call-holds?userId=<id>&status=PENDING
http://localhost:3035/api/twilio/call-holds/cleanup?limit=500
```

| Path | Main implementation | Notable libraries |
|------|---------------------|-------------------|
| `/api/twilio/voice/max-duration-preview` | `app/api/twilio/voice/max-duration-preview/route.ts` | `lib/billing/twilio-voice-pricing.ts` |
| `/api/twilio/voice/outbound` | `app/api/twilio/voice/outbound/route.ts` | `lib/call-max-duration.ts` |
| `/api/twilio/voice/inbound` | `app/api/twilio/voice/inbound/route.ts` | `getUserFromTwilioNumber()` in `lib/twilio.ts` |
| `/api/twilio/call-status` | `app/api/twilio/call-status/route.ts` | `lib/call-billing.ts`, `lib/wallet.ts` |
| `/api/twilio/client-call-metrics` | `app/api/twilio/client-call-metrics/route.ts` | provisional `Call`, `CallSession` helpers |
| `/api/twilio/sync-cost-jobs` | `app/api/twilio/sync-cost-jobs/route.ts` | `lib/call-cost-sync.ts` |
| `/api/twilio/refresh-voice-pricing-csv` | `app/api/twilio/refresh-voice-pricing-csv/route.ts` | `lib/twilio/refresh-outbound-voice-pricing-csv.ts` → `data/OutboundVoicePricing.csv` |
| `/api/twilio/sync-calls` | `app/api/twilio/sync-calls/route.ts` | recovery / settlement |
| `/api/twilio/call-holds` | `app/api/twilio/call-holds/route.ts` | — |
| `/api/twilio/call-holds/cleanup` | `app/api/twilio/call-holds/cleanup/route.ts` | — |

---

## Twilio SMS — Console webhooks vs app API

Configure **incoming** and **status** URLs in Twilio Console using your public base + path (same idea as voice).

| Method | Path                         | Purpose |
|--------|------------------------------|---------|
| `POST` | `/api/twilio/sms/inbound`    | Twilio **incoming message** webhook → `processInboundSms()` / conversations. |
| `POST` | `/api/twilio/sms-status`     | Twilio **outbound SMS status** callback (queued → delivered / failed); `lib/sms-status-precedence.ts`. |
| `POST` | `/api/sms/send`              | **Authenticated** outbound SMS from dashboard (not a Console URL). |

**Example (local)**

```text
http://localhost:3035/api/twilio/sms/inbound
http://localhost:3035/api/twilio/sms-status
```

| Path | Main implementation | Core logic |
|------|---------------------|------------|
| `/api/twilio/sms/inbound` | `app/api/twilio/sms/inbound/route.ts` | `lib/sms-inbound.ts` |
| `/api/twilio/sms-status` | `app/api/twilio/sms-status/route.ts` | `lib/sms-status-precedence.ts` |
| `/api/sms/send` | `app/api/sms/send/route.ts` | `lib/sms-send.ts` |

### SMS — multiple numbers per user

**Inbound** (`processInboundSms()` in `lib/sms-inbound.ts`):

- Twilio sends `To` = the Twilio number that received the SMS. Ringvoo resolves the owner with `TwilioNumber` where `phoneNumber` matches that E.164 (normalized).
- If no `TwilioNumber` row exists for `To`, the message is not stored (unknown number).
- If the row has a linked `VirtualNumber`, inbound is accepted only when `VirtualNumber.status` is `active` or `past_due`.
- One user can own many `TwilioNumber` rows; each `phoneNumber` is globally unique, so each inbound `To` maps to exactly one owner.

**Outbound** (`sendUserSms()` in `lib/sms-send.ts`, called from `POST /api/sms/send`):

- The client must send `fromNumber` (which line to use). The server checks `TwilioNumber` for `(userId = session user, phoneNumber = fromNumber)` — you cannot send from a number you do not own.
- The same `VirtualNumber` active/past_due rule applies as for inbound.
- Outbound destinations are restricted to US/Canada (`+1`) in `lib/sms-send.ts`.

**Conversations** (`SmsConversation`):

- Threads are keyed by `(userId, twilioNumberId, remoteNumber)` — one thread per **owned local line** and **remote party**. If the same user texts the same contact from two different lines, they get two separate conversations.

**Dashboard** (`components/dashboard/sms/SmsInboxClient.tsx`):

- Loads numbers via `GET /api/user/twilio-numbers`, defaults the compose `fromNumber` to the first number, and lets the user select another owned line when sending.

---

## Dashboard — wallet, calls, numbers (authenticated)

| Method | Path                                      | Purpose |
|--------|-------------------------------------------|---------|
| `GET`  | `/api/user/wallet`                        | Wallet + hold-aware **spendable** balance (`balance`, `activeHoldsUsd`, `availableBalanceUsd`). |
| `GET`  | `/api/user/wallet/transactions`           | Recent **wallet transactions** (paginated slice). |
| `GET`  | `/api/user/recent-calls`                  | **Dialer Recent** tab data (session user). |
| `GET`  | `/api/user/twilio-numbers`                | User’s **TwilioNumber** rows + configured voice E.164 from env. |

| Path | File |
|------|------|
| `/api/user/wallet` | `app/api/user/wallet/route.ts` |
| `/api/user/wallet/transactions` | `app/api/user/wallet/transactions/route.ts` |
| `/api/user/recent-calls` | `app/api/user/recent-calls/route.ts` |
| `/api/user/twilio-numbers` | `app/api/user/twilio-numbers/route.ts` |

---

## Public — marketing / unauthenticated pricing helpers

| Method | Path                               | Purpose |
|--------|------------------------------------|---------|
| `GET`  | `/api/public/voice-rates`          | Retail landline/mobile **per-minute** for `?iso=US` (two-letter country). |
| `GET`  | `/api/public/voice-rate-floor`     | Lowest retail USD/min from bundled CSV × multiplier (“up to X minutes” **floor**). |
| `GET`  | `/api/public/dialer-footer-preview` | Landing **dialer footer**: starting rate + max time for fixed demo balance (`DialerPreview`). Query: `?iso=US` (two-letter country). |

**Example (local)**

```text
http://localhost:3035/api/public/voice-rates?iso=US
http://localhost:3035/api/public/voice-rate-floor
http://localhost:3035/api/public/dialer-footer-preview?iso=US
```

| Path | File |
|------|------|
| `/api/public/voice-rates` | `app/api/public/voice-rates/route.ts` |
| `/api/public/voice-rate-floor` | `app/api/public/voice-rate-floor/route.ts` |
| `/api/public/dialer-footer-preview` | `app/api/public/dialer-footer-preview/route.ts` |

---

## SMS — dashboard APIs (authenticated)

| Method | Path                                                       | Purpose |
|--------|------------------------------------------------------------|---------|
| `GET`  | `/api/sms/conversations`                                   | List conversations for session user. |
| `GET`  | `/api/sms/conversations/:conversationId/messages`          | Messages in one conversation. |
| `POST` | `/api/sms/conversations/:conversationId/read`              | Mark conversation read. |
| `GET`  | `/api/sms/logs`                                            | SMS log lines (dashboard). |
| `GET`  | `/api/sms/usage`                                           | Usage / quota style metrics for SMS. |

`:conversationId` = Prisma `SmsConversation.id`.

| Path pattern | File |
|--------------|------|
| `/api/sms/conversations` | `app/api/sms/conversations/route.ts` |
| `/api/sms/conversations/[conversationId]/messages` | `app/api/sms/conversations/[conversationId]/messages/route.ts` |
| `/api/sms/conversations/[conversationId]/read` | `app/api/sms/conversations/[conversationId]/read/route.ts` |
| `/api/sms/logs` | `app/api/sms/logs/route.ts` |
| `/api/sms/usage` | `app/api/sms/usage/route.ts` |

---

## Virtual numbers — search, checkout, subscription

| Method | Path                               | Purpose |
|--------|------------------------------------|---------|
| `GET`  | `/api/numbers/search`              | Search **available** numbers (`country` US/CA; optional `areaCode`, `contains`). |
| `POST` | `/api/numbers/create-checkout`     | Stripe **checkout** for purchasing a virtual number. |
| `POST` | `/api/numbers/manage-subscription` | **Portal** / manage subscription for owned numbers. |
| `POST` | `/api/numbers/retry-provisioning`  | Retry failed **provisioning** after payment. |
| `PATCH`| `/api/numbers/:id`                 | Update **friendly name** for the user’s virtual number (`body.friendlyName`). |

| Path pattern | File |
|--------------|------|
| `/api/numbers/search` | `app/api/numbers/search/route.ts` |
| `/api/numbers/create-checkout` | `app/api/numbers/create-checkout/route.ts` |
| `/api/numbers/manage-subscription` | `app/api/numbers/manage-subscription/route.ts` |
| `/api/numbers/retry-provisioning` | `app/api/numbers/retry-provisioning/route.ts` |
| `/api/numbers/[id]` | `app/api/numbers/[id]/route.ts` |

---

## Billing, Stripe & promos

| Method | Path                               | Purpose |
|--------|------------------------------------|---------|
| `POST` | `/api/billing/checkout-session`    | Wallet **top-up** / checkout session. |
| `POST` | `/api/billing/setup-payment-method`| Save **default payment method** (setup session). |
| `GET`  | `/api/billing/checkout-return`     | Post-checkout **return** handling. |
| `GET`  | `/api/billing/rewards`             | First-purchase **rewards** / coupon state. |
| `POST` | `/api/billing/promo/validate`      | Validate **promotion** code. |
| `POST` | `/api/billing/post-purchase-feedback` | Post-purchase **feedback** capture. |
| `GET`/`PATCH` | `/api/user/billing/auto-topup` | **Auto top-up** settings. |
| `POST` | `/api/webhooks/stripe`             | Stripe **webhook** endpoint. |

| Path | File |
|------|------|
| `/api/billing/checkout-session` | `app/api/billing/checkout-session/route.ts` |
| `/api/billing/setup-payment-method` | `app/api/billing/setup-payment-method/route.ts` |
| `/api/billing/checkout-return` | `app/api/billing/checkout-return/route.ts` |
| `/api/billing/rewards` | `app/api/billing/rewards/route.ts` |
| `/api/billing/promo/validate` | `app/api/billing/promo/validate/route.ts` |
| `/api/billing/post-purchase-feedback` | `app/api/billing/post-purchase-feedback/route.ts` |
| `/api/user/billing/auto-topup` | `app/api/user/billing/auto-topup/route.ts` |
| `/api/webhooks/stripe` | `app/api/webhooks/stripe/route.ts` |

---

## Admin

| Method | Path                         | Purpose |
|--------|------------------------------|---------|
| `GET`  | `/api/admin/users/role`      | Read user **role** by `?email=` (super-admin only). |
| `PATCH`| `/api/admin/users/role`      | Promote / demote role. Body: `{"email":"…","role":"ENTERPRISE_ADMIN"}`. |
| `GET`  | `/api/admin/sms-risk`        | Super-admin SMS risk rows (negative-balance users + inbound `negative_balance_floor` skip counters for 24h/7d). |
| `POST` | `/api/admin/users/:id/topup-reminder` | Super-admin manual top-up reminder email trigger for one user. |

**Example (local)**

```text
http://localhost:3035/api/admin/users/role?email=user@example.com
```

| Path | File |
|------|------|
| `/api/admin/users/role` | `app/api/admin/users/role/route.ts` |
| `/api/admin/sms-risk` | `app/api/admin/sms-risk/route.ts` |
| `/api/admin/users/[id]/topup-reminder` | `app/api/admin/users/[id]/topup-reminder/route.ts` |

---

## Auth & placeholders

| Method | Path                    | Purpose |
|--------|-------------------------|---------|
| `*`    | `/api/auth/[...nextauth]` | NextAuth **catch-all** (OAuth, session, callbacks). |
| `POST` | `/api/auth/register`    | Registration. |
| `POST` | `/api/auth/forgot-password` | Password reset request. |
| `POST` | `/api/auth/reset-password`  | Password reset confirm. |
| `GET`  | `/api/auth/verify-email`    | Email verification. |
| `GET`  | `/api/auth/token`       | **Legacy** placeholder; use `/api/twilio/token` for Voice SDK. |
| `POST` | `/api/webhooks`         | **Placeholder** generic webhook (prefer specific Twilio routes above). |
| `POST` | `/api/call`             | **Placeholder** server-initiated outbound (not used by in-app dialer). |
| `GET`  | `/api/users`            | **Placeholder** user/wallet JSON. |

---

## How rates are used

- Max duration limiter uses the **highest** country rate (retail):  
  `getMaxRetailRatePerMinuteUsdForIsoCountry()` in `lib/billing/twilio-voice-pricing.ts`
- Inbound max duration / hold limiter uses the **highest** inbound country rate (retail):  
  `getMaxInboundRetailRatePerMinuteUsdForIsoCountry()` in `lib/billing/twilio-voice-pricing.ts`
- Marketing preview uses the **lowest** country rate (retail):  
  `getMarketingStartingRetailRatePerMinuteUsd()` in `lib/billing/twilio-voice-pricing.ts`
- Final customer debit uses **actual Twilio call cost** × multiplier:  
  `splitTwilioAndRetailCharge()` in `lib/billing/voice-pricing.ts`
- “Available balance” for voice admission is the **wallet gross balance** (holds are legacy/no-op):  
  `getAvailableBalanceUsd()` in `lib/call-holds.ts` (returns `wallet.balance` with `activeHoldsUsd = 0`)
- Client metrics can still supply **fallback duration** when Twilio callbacks lag (used to improve provisional rows / settlement timing), but it is **not** a financial “hold compression” mechanism.

---

## Inbound ownership model

Inbound ownership is determined by Ringvoo's database, not by Twilio subaccounts or per-user Twilio ownership.

- Twilio knows only that the purchased phone number belongs to the Ringvoo Twilio account.
- Ringvoo decides which app user owns that number by storing a mapping in `TwilioNumber`.
- The canonical ownership lookup is `getUserFromTwilioNumber()` in `lib/twilio.ts`.
- That lookup reads `TwilioNumber.phoneNumber -> TwilioNumber.userId`.
- `TwilioNumber.phoneNumber` is unique globally, so each inbound `To` number resolves to exactly one owner.
- A user can own multiple numbers (multiple `TwilioNumber` rows sharing the same `userId`).
- If a `TwilioNumber` row is linked to a `VirtualNumber`, inbound routing is allowed only when `VirtualNumber.status` is `active` or `past_due`.
- If no `TwilioNumber` row exists for the inbound `To` number, Ringvoo does not route that inbound call to a browser client.

Example:

```json
{
  "phoneNumber": "+18153678725",
  "userId": "cmne9lz3i0000z27wovrrqhgs",
  "friendlyName": "Main Twilio number"
}
```

Meaning:

- the Twilio number is owned at Twilio-account level by Ringvoo
- Ringvoo routes inbound calls for that number to the user `cmne9lz3i0000z27wovrrqhgs`
- Ringvoo also bills inbound usage for that number to that same user

This is the expected SaaS model for future number purchase as well:

1. Ringvoo buys the number using the shared Twilio account
2. Ringvoo inserts a `TwilioNumber` row for the purchasing user
3. inbound calls to that number resolve back to that user via `TwilioNumber`

Development/testing note:

- Manually inserting `TWILIO_PHONE_NUMBER` into `TwilioNumber` is a valid local test setup for inbound routing.
- In production flow, numbers are expected to be created through virtual-number provisioning, which also creates/links `VirtualNumber` status used by inbound eligibility checks.

---

## Inbound billing flow

Main files:

- `app/api/twilio/voice/inbound/route.ts`
- `app/api/twilio/call-status/route.ts`
- `lib/call-billing.ts`
- `lib/voice-call-validation.ts`, `lib/voice-call-sessions.ts`, `lib/voice-dial-time-limit.ts`
- `lib/call-holds.ts` (legacy API surface; holds are **no-op** in current schema)
- `lib/call-cost-sync.ts`
- `components/dashboard/TwilioProvider.tsx`

Lifecycle:

1. Twilio sends inbound webhook to `POST /api/twilio/voice/inbound` (local: `http://localhost:3035/api/twilio/voice/inbound`)
2. Ringvoo resolves the called Twilio number in `TwilioNumber`
3. Ringvoo runs the same **wallet admission** path as outbound (`validateVoiceCallStart`) using an **inbound** max retail estimate (`getInboundVoiceEstimateForIso`)
4. On success, Ringvoo creates a **`CallSession`** and sets `<Dial timeLimit>` from `computeVoiceDialTimeLimitSeconds` (includes low-balance stepping when applicable)
5. Ringvoo returns TwiML to dial the browser client for that user
6. Twilio emits callbacks for the browser/client leg and terminal completion
7. Ringvoo resolves the correct billable leg and settles the call through `upsertCallAndSettleWallet()`
8. Ringvoo debits the wallet (estimated-first when Twilio `price` is late) and ends/binds matching **`CallSession`** rows (no `CallHold` settlement)

---

## Parent and child Twilio legs

Inbound browser-connected calls usually have 2 Twilio call legs:

- Parent leg: PSTN caller → Twilio number
- Child leg: Twilio → `client:<userId>`

For inbound settlement, the canonical persisted/billed call is the child billed leg:

- `Call.twilioCallSid` = child billable leg
- `Call.twilioParentCallSid` = parent inbound leg
- `CallSession.twilioParentCallSid` / optional `billableCallSid` track the parent→child relationship for estimated settlement + duration fallbacks

Why:

- Twilio's final priced/completed browser-connected call is the leg used for actual settlement
- the parent leg is still important for correlation and session binding

### Outbound: dashboard dialer (browser → PSTN)

When a user calls a normal phone number from the **WebRTC dialer**, Twilio still uses two conceptual legs:

1. **Parent leg (browser → your TwiML App)**  
   Twilio’s `From` on webhooks for this leg is **`client:<userId>`** — the Voice SDK identity from `/api/twilio/token` (same as `User.id` in Prisma). This is **not** the caller ID the callee sees.

2. **Child leg (Twilio → callee’s number)**  
   The PSTN leg uses **`callerId`** from TwiML (`<Dial callerId="…">`) in `POST /api/twilio/voice/outbound`. That value is the **Ringvoo public number**, an **owned virtual number**, or a **verified custom caller ID**, depending on the user’s “Call from” selection.

**Reading it in Twilio Console**

1. Open **Monitor → Logs → Calls** (or **Develop → Monitor → Call logs**, depending on Console layout).
2. Paste the **`CallSid`** from Ringvoo server logs (or search by destination number / time).
3. Open the call; if Twilio shows **related calls** or a **child call**, open the leg whose **To** is the destination country number (e.g. `+971…`). On that leg, **From** should match the PSTN caller ID you chose (e.g. `+1507…`), not `client:…`.
4. The leg where **From** is `client:…` is the browser leg; use it only to correlate with the user session, not as “what number was displayed.”

**Ringvoo logs**

With `TWILIO_VOICE_LOG_OUTBOUND=true`, outbound TwiML generation logs **`twilioDeviceFrom`** (always `client:…` on the SDK leg), **`pstnCallerId`** (the E.164 used on `<Dial>`), and **`twilioAccountAuthorizesCallerId`** when “Call from” is owned/custom: `true` only if that E.164 is an **Incoming Phone Number** or **Verified Caller ID** on the same Twilio Account SID. Ringvoo’s in-app OTP does **not** by itself register the number with Twilio; if this flag is `false`, add the number under Twilio Console → **Phone Numbers** → **Verified Caller IDs** (or use a Twilio-purchased number). Billing-related logs may still show `From` as `client:…` because they follow Twilio’s parent-leg fields; for display and accounting, prefer the persisted **`Call.fromNumber` / `Call.toNumber`** once `upsertCallAndSettleWallet` has normalized the billable leg.

Optional: `TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=true` forces fallback to `TWILIO_PHONE_NUMBER` when Twilio would not honor the selected PSTN caller ID (connectivity over custom CLI).

### Caller-ID source of truth and API usage

Ringvoo intentionally uses **two checks** for custom caller IDs:

1. **Product permission (Ringvoo DB)**  
   `UserCallerId.verified=true` means the user can choose that number in the dialer.
2. **Twilio account authorization (Twilio REST)**  
   `isTwilioDialCallerIdAllowed(e164)` checks Twilio Incoming Numbers + Outgoing Caller IDs on this Account SID.

Important:

- DB verification and Twilio authorization are related but not identical states.
- Outbound TwiML still sets `<Dial callerId="...">` from selected mode; Twilio then decides network-level presentation behavior.
- If Twilio authorization is false and fallback env is off, Ringvoo logs a warning and still sends the selected callerId.

API/route usage in this flow:

- `POST /api/user/caller-ids`:
  - normalizes E.164
  - enforces cooldown
  - calls Twilio `validationRequests.create` when needed
  - stores `verificationCode`, `verificationCallSid`, `expiresAt`, and status metadata
- `POST /api/twilio/outgoing-caller-id/validation-status`:
  - Twilio webhook with `VerificationStatus`
  - marks verified on success
- `GET /api/user/caller-ids`:
  - returns caller IDs for modal/dropdown
  - syncs pending rows against Twilio authorization
  - normalizes stale in-flight verification statuses after expiry
- `POST /api/twilio/voice/outbound`:
  - applies `CallFromMode` (`public`, `phone`, `custom`)
  - sets `<Dial callerId>`
  - optionally falls back to `TWILIO_PHONE_NUMBER`

### `CallFromMode` reference (dialer → TwiML)

`CallFromMode` is sent from `DialerPreview` to `POST /api/twilio/voice/outbound` and controls caller ID source:

- `public` → use `TWILIO_PHONE_NUMBER`
- `phone` → use selected owned number (`CallFromNumber`)
- `custom` → use selected verified custom caller ID (`CallFromNumber`)

Server log fields for each outbound attempt:

- `callFromMode`
- `pstnCallerId`
- `twilioAccountAuthorizesCallerId` (for `phone`/`custom`)

Practical debugging rule:

- If the callee sees unexpected caller ID, first confirm `callFromMode` + `pstnCallerId` in `[Twilio Voice] Outbound call TwiML generated`.
- If `callFromMode: 'public'`, the dialer sent public mode; this is not Twilio fallback logic.
- If `callFromMode: 'custom'` and `twilioAccountAuthorizesCallerId: false`, behavior depends on fallback env and Twilio/carrier handling.

Recent bug note (fixed):

- A UI state desync could occasionally show a selected number label while sending `CallFromMode='public'`.
- Dialer now performs a pre-connect consistency check:
  - if label matches an owned number, force `phone`
  - if label matches a verified custom number, force `custom`
  - else keep `public`

### Custom caller-ID behavior matrix (outbound PSTN)

| User selection | Twilio authorization (`isTwilioDialCallerIdAllowed`) | `TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED` | `<Dial callerId>` sent by Ringvoo | Expected presentation behavior |
|---|---:|---:|---|---|
| `public` | n/a | n/a | `TWILIO_PHONE_NUMBER` | Callee generally sees Twilio public number. |
| `phone` (owned number) | true | any | owned number | Callee generally sees owned number. |
| `custom` | true | any | custom number | Callee generally sees custom number. |
| `custom` | false | false | custom number | Ringvoo warns in logs; Twilio/carrier behavior may vary (reject/substitute/present). Not guaranteed. |
| `custom` | false | true | `TWILIO_PHONE_NUMBER` | Deterministic app fallback; callee should see public number. |

### Scenario: custom caller ID removed from Twilio Console

Observed in testing: after removing a custom caller ID from Twilio Console, some calls still showed that number to the callee while logs reported `twilioAccountAuthorizesCallerId=false`.

How to interpret this correctly:

- Treat this as **runtime/network carrier behavior**, not a guarantee.
- Ringvoo did its part by detecting unauthorized state via Twilio REST.
- Without fallback env, Ringvoo still sends selected custom callerId in TwiML.
- Twilio/carrier may still present that caller ID on some attempts/routes, or may substitute/reject on others.

Operational recommendation:

- If you want deterministic behavior after de-authorization, set  
  `TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=true`.
- If you want strict policy, keep custom IDs synchronized between Ringvoo and Twilio account state.

### Verification modal lifecycle scenarios (custom caller ID)

| Scenario | Twilio/DB state | Expected UI state |
|---|---|---|
| Verification call placed, user answers and enters code | `verified=true` via webhook/sync | Row moves to `Verified`, code hint clears. |
| Verification call no-answer / no digits | status may end while `verified=false` | Modal should leave `Verifying...`/`Processing...` after poll/expiry and allow retry. |
| Twilio callback delayed/missed | stale in-flight status in DB | `GET /api/user/caller-ids` now normalizes expired stale in-flight rows to `completed` so UI does not freeze. |
| Retry cooldown active | `nextAttemptAt > now` | UI shows wait message; API returns 429 + `Retry-After`. |

### Settlement scenarios for custom caller ID calls

Custom caller ID can affect ownership resolution on billing webhooks:

- `from` may be a custom E.164 that is not mapped in `TwilioNumber`.
- Owner resolution falls back through Twilio client identities (`client:<userId>`) and/or parent/child SID correlation paths in `lib/call-billing.ts` (not `CallHold`).

Settlement outcomes:

- terminal failed-like statuses (`busy`, `failed`, `no-answer`, `canceled`) settle as zero-charge
- if Twilio price is delayed, cost-sync job may be required in rare cases (`/api/twilio/sync-cost-jobs`)

---

## Inbound charging rules

Inbound charging is intentionally simple:

- ringing only: no charge
- rejected: no charge
- no-answer: no charge
- busy: no charge
- failed: no charge
- caller hangs up before answer: no charge
- answered inbound call: billable

For a successful answered inbound call:

- `answeredAt` is stored
- `billableDurationSeconds` is set from the final/fallback duration
- `isBillable = true`
- `billingStatus = settled` after final settlement

---

## Inbound admission + `<Dial timeLimit>`

Inbound TwiML uses the same **admission + max talk time** approach as outbound, but priced with **inbound** max retail:

At call start:

- Ringvoo checks **`wallet.balance > 0`** (`validateVoiceCallStart`)
- Ringvoo estimates inbound max retail $/min (`getInboundVoiceEstimateForIso`)
- Ringvoo creates a **`CallSession`** row for settlement/idempotency
- Ringvoo sets `<Dial timeLimit>` from `computeVoiceDialTimeLimitSeconds` (includes **low-balance stepping** when applicable)

For the currently intended product scope, inbound numbers are expected to be US/Canada numbers purchased inside Ringvoo.

Important distinction:

- `<Dial timeLimit>` uses a conservative **highest inbound retail** estimate for the owning number’s country
- Final charge uses the actual Twilio call `price` for the completed billed leg, then applies the Ringvoo multiplier (`CallSettlement` reconciles estimated vs final when needed)

---

## Custom caller-ID verification billing note

- Verification calls are currently **free for users** (no wallet debit).
- Twilio verification call cost is currently platform-paid.
- To inspect Twilio cost/duration in terminal for these calls, enable:
  - `TWILIO_LOG_CUSTOM_CALLER_ID_COST=true`

---

## Inbound settlement and idempotency

Final settlement happens in `upsertCallAndSettleWallet()` in `lib/call-billing.ts`.

Protection layers:

- one wallet debit per logical call via `Transaction.referenceId + source`
- inbound wallet debits use `source = "inbound_call"`
- `CallSettlement.callSid` is the idempotency anchor for the billed leg; `CallSession` binds parent/child SIDs for estimated settlement paths
- serializable Prisma transactions are used for settlement
- `pg_advisory_xact_lock(hashtext(callSid))` serializes settlement work for the same call SID
- delayed price sync uses `CallCostSyncJob.callSid` unique retry jobs

This means duplicated or delayed Twilio callbacks should not create:

- duplicate `Call` rows for the same child billed leg
- duplicate wallet deductions
- duplicate settlement rows for the same priced leg

---

## Client fallback metrics

`POST /api/twilio/client-call-metrics` is best-effort fallback metadata, not the primary source of financial truth.

It helps Ringvoo recover:

- `acceptedAt`
- `endedAt`
- fallback duration
- parent/child relation when client-side timing is available earlier than Twilio's final priced callback

Primary use:

- improve `answeredAt`
- improve fallback duration
- improve settlement recovery if Twilio callback timing is delayed

---

## Expected final DB shape for a good answered inbound call

`Call`:

- `direction = 'inbound'`
- `twilioCallSid = child billed leg`
- `twilioParentCallSid = parent inbound leg`
- `status = 'completed'`
- `answeredAt` populated
- `duration` populated
- `billableDurationSeconds > 0`
- `isBillable = true`
- `twilioCost > 0`
- `cost > 0`
- `billingStatus = 'settled'`

`CallSession`:

- `twilioParentCallSid = parent inbound leg`
- `billableCallSid` eventually matches the **child billed leg**
- `status = 'ENDED'` after terminal processing

`CallSettlement`:

- `callSid = child billed leg`
- `amountUsd = Call.cost` (retail charged)
- `twilioCarrierCostUsd` populated when Twilio price is known (may be filled after an estimated-first path)

`Transaction`:

- `type = 'CALL'`
- `source = 'inbound_call'`
- `referenceId = Call.twilioCallSid`
- `amount = Call.cost`

---

## Frontend components involved

- Dialer UI (toast/audio gates + a few modals like end-of-limit / contacts / caller-id flows):
  - `components/marketing/DialerPreview.tsx`
- Twilio client lifecycle (online/offline, register, incoming/outgoing wiring):
  - `components/dashboard/TwilioProvider.tsx`

---

## Voice UX env knobs

| Variable                                  | Purpose |
|-------------------------------------------|---------|
| `NEXT_PUBLIC_VOICE_BACK_TO_BACK_HINT_WINDOW_MS` | Back-to-back debounce window while settlement/pricing catches up. Default `4000` ms (min `500`, max `15000`). Used by `components/marketing/DialerPreview.tsx`. |
| `NEXT_PUBLIC_DIALER_RINGBACK_AUDIO_SRC` | Local outbound ringback URL (default `/sounds/ringvoo-ringback-cc0.mp3`). |
| `NEXT_PUBLIC_DIALER_CONNECT_MESSAGE_AUDIO_SRC` | Local outbound “please wait …” line (default `/sounds/ringvoo-connect-message.wav`). |
| `NEXT_PUBLIC_DIALER_HANGUP_TONE_AUDIO_SRC` | Local hangup tone (default `/sounds/ringvoo-end-call.mp3`). |
| `NEXT_PUBLIC_INCOMING_RINGTONE_AUDIO_SRC` | Browser inbound ringtone (default `/sounds/ringvoo-incoming.mp3`). |
| `NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_AUDIO_SRC` / `NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_AUDIO_SRC` | Marketing dialer low-balance block cues (user vs org member). |
| `NEXT_PUBLIC_DIALER_FINALIZING_AUDIO_SRC` / `NEXT_PUBLIC_DIALER_FINALIZING_MESSAGE` | Marketing dialer back-to-back “finalizing” cue + toast text. |
| `NEXT_PUBLIC_DIALER_CALL_NOT_COMPLETED_AUDIO_SRC` | Marketing dialer early-failure cue (before connect). |

Full policy + audio map: [`docs/RINGVOO_SYSTEM_NOTES.md`](docs/RINGVOO_SYSTEM_NOTES.md#local-dialer--browser-audio-files).

---

## Voice log env knobs

| Variable                      | Purpose |
|------------------------------|---------|
| `TWILIO_SERVER_LOGS`         | Global master switch for Twilio server logs. |
| `TWILIO_SERVER_LOG_SCOPES`   | Optional scope filter: `voice`, `billing`, `recording`, `rest`, `sms`. |
| `TWILIO_VOICE_LOG_INBOUND`   | Optional **inbound** voice override (preferred; aligns with `TWILIO_SMS_LOG_*`). When set, overrides global `voice` logging for `app/api/twilio/voice/inbound` only. Legacy: `TWILIO_INBOUND_SERVER_LOGS` if unset. |
| `TWILIO_VOICE_LOG_OUTBOUND`  | Optional **outbound** voice override for `app/api/twilio/voice/outbound` only. Legacy: `TWILIO_OUTBOUND_SERVER_LOGS` if unset. |
