# WhatsApp + web loan booking (direct borrower)

Hybrid channel: Baileys WhatsApp bot + public `book/` wizard.

The bot no longer asks for an affiliate code. When a customer chooses to apply, an affiliate is
picked automatically from an admin-managed pool and their `referralCode` is passed to
`POST /auth/initiate-signup`, so `users.referred_by_affiliate_id` is set at account creation and
the borrower lands under that affiliate's referrals. A code the customer volunteers still wins.

## Services

| Service | Port | Path |
|---------|------|------|
| API | 8000 | `api/` |
| Admin WhatsApp UI | 4000 | `/whatsapp` |
| Book wizard | 4002 | `book/` |
| WhatsApp gateway | 4010 | `whatsapp/` |

## Env

**API (`api/.env`):**

```
WHATSAPP_BOT_TOKEN=dev-whatsapp-bot
BOOK_WEB_BASE_URL=http://127.0.0.1:4002
WHATSAPP_GATEWAY_URL=http://127.0.0.1:4010
WHATSAPP_GATEWAY_ADMIN_TOKEN=dev-whatsapp-admin
```

**WhatsApp (`whatsapp/.env`):** see `whatsapp/.env.example` — set `OPENAI_API_KEY` for LLM NLU (falls back to rules).

**Book (`book/.env.local`):**

```
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000/api/v1
```

## AI lead routing

The bot collects full KYC (signup → BVN → bank → employer/payroll) and stops **before any
financial authorisation**, then hands the customer to their assigned agent. Move the seam with
`AI_HANDOFF_AFTER` in `whatsapp/.env`:

| Value | Bot stops after |
|---|---|
| `employment` (default) | payroll verification — nothing financial authorised |
| `bank_statement` | statement upload, before the mandate |
| `never` | legacy fully-automated journey |

Selection is weighted-random with a least-recently-assigned tiebreak, skipping members who are
paused, over their daily cap, deactivated or unverified. An empty pool leaves the lead unassigned
(visible to admins under **WhatsApp → AI Leads**) rather than blocking the chat.

Tables: `ai_lead_pool_members`, `ai_leads`, `ai_lead_events` (append-only audit trail).
`ai_leads.collected` never stores BVN, passwords, OTPs, PINs or account numbers.

### Completing the loan

Two paths, both available on every lead:

1. **Continue booking in dashboard** — the agent picks the customer up in the normal affiliate
   onboarding wizard (`GET /affiliate/ai-leads/{uuid}/resume`, a thin wrapper over the existing
   `GET /affiliate/borrowers/{id}/resume`). The AI's work is already saved on the borrower, so the
   wizard resumes where the bot stopped. Ordinary agent work — no special permission.
2. **Send secure link** — for the mandate/OTP step the customer must complete themselves.

On the Identity & Bank screen, BVN and account number captured earlier come back **masked**
(`*******4455`) as read-only fields. The agent confirms the last 4 digits with the customer and
submits; `VerifyStageTwoAction::resolveMaskedIdentity()` swaps the stored digits back in
server-side, so the agent never handles the real values. "Enter different details" clears the
fields for a genuine correction, and anything the agent actually types always wins.

The full BVN stays in `users.bvn` and remains visible to admins (`GET /admin/users/{id}`) —
masking is a display rule for affiliates, not a change to what is stored.

Default path is **Send secure link**: the affiliate issues a one-time magic link that goes to the
customer's own WhatsApp/email, and the customer enters their own OTP and signs their own mandate
in `book/`. Agents must never ask a customer to read out an OTP.

An audited fallback exists for customers who cannot use links: `affiliate.otp_fallback` permission,
a mandatory written reason, an `otp_fallback_engaged` event, and an operations notification on every
use. It is off by default and granted per-affiliate in Admin → Partners.

## Bot APIs (Bearer `WHATSAPP_BOT_TOKEN`)

- `POST /api/v1/whatsapp/sessions`
- `GET/PATCH /api/v1/whatsapp/sessions/{uuid}`
- `POST /api/v1/whatsapp/sessions/{uuid}/magic-link`
- `POST /api/v1/whatsapp/sessions/{uuid}/assign-affiliate` — claim an agent (idempotent)
- `PATCH /api/v1/whatsapp/sessions/{uuid}/lead` — merge collected snapshot
- `POST /api/v1/whatsapp/sessions/{uuid}/handoff` — finish collection, notify agent + customer
- `POST /api/v1/whatsapp/tickets`

## Public / borrower

- `POST /api/v1/whatsapp/sessions/{uuid}/redeem` `{ token }` → Sanctum `access_token`
- `POST /api/v1/whatsapp/sessions/{uuid}/claim` (auth)
- `GET /api/v1/whatsapp/sessions/{uuid}/me` (auth)
- `PATCH /api/v1/whatsapp/sessions/{uuid}/progress` (auth)

## Affiliate

- `GET /api/v1/affiliate/ai-leads` (+ `/{uuid}`)
- `GET /api/v1/affiliate/ai-leads/{uuid}/resume` — continue in the booking wizard
- `POST /api/v1/affiliate/ai-leads/{uuid}/secure-link` — customer self-completes the mandate
- `POST /api/v1/affiliate/ai-leads/{uuid}/contacted`
- `POST /api/v1/affiliate/ai-leads/{uuid}/notes`
- `POST /api/v1/affiliate/ai-leads/{uuid}/otp-fallback` — gated + audited

UI: Partner app → **AI Leads**.

## Admin

- `GET/POST /api/v1/admin/ai-leads/pool`, `PATCH|DELETE /admin/ai-leads/pool/{id}`
- `GET /api/v1/admin/ai-leads/pool/candidates`
- `GET /api/v1/admin/ai-leads` (+ `/{uuid}`), `POST /admin/ai-leads/{uuid}/reassign`
- `GET /api/v1/admin/whatsapp/sessions`
- `GET /api/v1/admin/whatsapp/tickets`
- `POST /api/v1/admin/whatsapp/tickets/{id}`
- Gateway proxy: `/admin/whatsapp/gateway/{status,qr,pairing,logout}`

## Simulate chat (no phone)

```bash
curl -s -X POST http://127.0.0.1:4010/simulate \
  -H "Authorization: Bearer dev-whatsapp-admin" \
  -H "Content-Type: application/json" \
  -d '{"phone":"2348011111111","text":"menu"}'
```

## Smoke path

1. Start stack: `./start-dev.sh`
2. Admin → WhatsApp → scan QR / pairing
3. Admin → WhatsApp → **AI Lead Pool** → add at least one verified affiliate
4. Simulate or chat: Apply → new customer → signup → identity → employer → agent hand-off
5. Partner app → **AI Leads** → open the lead → Continue booking, or Send secure link
6. Open link on `book/` → eligibility → preview → `POST /loans/requests`
7. Repay: menu → loan reference → `/repay/{reference}`

An empty pool is the usual cause of leads showing as unassigned.
