# Architecture

## Components
```
WooCommerce site ──(Bearer key_id.secret, HTTPS)──► /api/v1  ──► Call Engine ──► VoiceProviderInterface ──► provider API
        ▲                                                          │  ▲                                        │
        └──(HMAC-signed push /wp-json/voiceorder/v1/call-result)───┘  └──── /api/v1/webhooks/voice/{slug} ◄────┘ (HMAC verified)
```
* **Call Engine** (`App\Services\CallEngine`) never talks to a vendor API directly – only to `VoiceProviderInterface` (`initiateCall, getCallStatus, verifyWebhook, normalizeResponse, getCapabilities, testConnection`).
* **Adapters** (`config/voiceorder.php → adapters`): `tzsmm` (`TZSMMConnectProvider`), `custom` (`CustomVoiceProvider`, template-driven, no code execution). Everything provider-specific is data on `voice_providers` (secrets encrypted).
* **Queue**: table `call_queue` (`dispatch | reconcile | notify_site`), drained by `php artisan voiceorder:process-queue` (cron, locked, bounded batch).
* **Billing**: `WalletService` – every movement is a row in append-only `wallet_transactions`, inside a DB transaction with the wallet row `lockForUpdate()`.

## Call lifecycle & states
`queued → dispatching → accepted → ringing → answered → completed`; side exits `rejected | failed | no_answer | busy | cancelled | unknown | awaiting_reconciliation`.

1. Plugin posts `/orders/events` (idempotent: one automatic call per `(website, order)`).
2. Engine validates client/website/phone, picks the **active provider at creation time and stores it on the call** (switching providers never touches existing calls).
3. **Reserve**: free credit first, else wallet balance (`reserve` ledger row; call → `charge_status=reserved`). Insufficient funds → 402, no call created.
4. Queue row `dispatch` runs at `now + client delay`.
5. Dispatch outcome classification (`ProviderResult`):
   * `accepted` → **capture** (reservation becomes a charge, exactly once) → state `accepted`.
   * `rejected` (provider definitively refused: 4xx, success flag false) → **release** reservation, state `rejected`, client is not charged.
   * `temporary_failure` (request provably not sent: DNS/connect error, 429/502/503) → retry per policy, else release + `failed`.
   * `ambiguous` (read timeout, 500, dropped) → state `awaiting_reconciliation`; **never re-dialled automatically**. The engine polls `getCallStatus`; a verified provider callback also resolves it; otherwise an admin resolves it manually.
6. Provider webhook → verify signature (raw body, timing-safe) → dedupe (`webhook_events` unique `(provider, event_key)`) → `normalizeResponse` → `applyEvent` (looks up the call **only via the original provider**).
7. Digit `1` → `confirmed`, `2` → `cancelled` (first response wins) → `notify_site` queue row → signed push to the plugin.
8. No answer/busy/failed → retry while `attempts < max_attempts`, else final `no_answer`; order status is never changed.

### Billing policy (configurable, Admin → Pricing & settings)
`first_attempt_only` (default): client is charged once per order call; retry attempts cost the platform provider fee (recorded per attempt in `call_attempts.provider_cost`).
`per_attempt`: every accepted retry is charged again. Four numbers are kept separately: provider cost, client price, free-call expense (`provider_cost` where `funding_source=free`), margin (`charged − provider_cost`).

## Website limits
* `client_websites.normalized_domain` is **globally unique** (one domain ↔ one client, forever) and rows are never deleted.
* A client may connect a new domain only if `count(all their website rows incl. disconnected/revoked) < website_limit` (default 1) **or** an approved `website_connection_requests` row exists for that domain. Disconnecting therefore never frees the slot.
* Reconnecting the same normalized domain reuses its row. Free credits are awarded once per client (`free_call_credits.signup_key = signup:{client_id}` UNIQUE + `clients.free_credits_awarded_at`).
* Ownership: connect returns a `verification_token`; the plugin serves `sha256(token|domain)` at `/wp-json/voiceorder/v1/ownership`; the SaaS fetches it (SSRF-guarded) before the site becomes `active`.

## Roles
`super_admin`, `admin`, `support_agent`, `finance_manager` (staff, `App\Support\Permissions`) and `client_owner`, `client_staff` (tenant users). Provider credentials, provider switching, pricing/settings, staff, plugin releases, ambiguous-call resolution: **super_admin only**.

## Database (migrations: `database/migrations`)
clients, users, sessions, password_reset_tokens, voice_providers, client_websites, website_connection_requests, website_domain_verifications, website_connection_history, pairing_codes, api_credentials, wallets, wallet_transactions, free_call_credits, recharge_requests, orders, calls, call_attempts, call_queue, call_events, webhook_events, provider_call_logs, client_settings, system_settings, support_tickets, support_ticket_messages, notifications, admin_activity_logs, plugin_releases, plugin_download_logs, api_idempotency_keys, replay_nonces, cache, jobs.
Differences from the suggested list: roles/permissions are a `users.role` column + `Permissions` map (no separate tables); `voice_templates` is folded into `client_settings.data`; `orders` and `pairing_codes` were added.
Immutability is enforced in the Eloquent layer (`WalletTransaction`, `FreeCallCredit` throw on update/delete). If you need DB-level enforcement add MySQL triggers (not shipped).
