# Payment → WhatsApp → AI Call automation — build spec

**Status:** planning only. Nothing in this document is built yet.
**Written:** 2026-08-31, by an exploration session. The implementing session should
read the files listed in §8 before writing code.
**Mandatory reading first:** `CLAUDE.md`, `GUIDELINES.md` (§14 for the admin list
page pattern, §2 for the repository/transaction rules), and
`docs/modules_handbook/shared/voice-agent/readMe.md`.

---

## 1. What the owner asked for

On **`/manage/payment-links`** each payment item (e.g. *Binastra Cochrane — Project
fee MYR 100.00*) should carry an **admin-configurable automation**:

1. **Payment received** → send a chosen WhatsApp template to the buyer's phone.
2. **AI Caller brain** → the admin picks an `AiCallProfile` for this payment item,
   and can **manually press "Call now"** on a lead who has paid.
3. **After the AI call ends** → send a chosen WhatsApp template.
4. **If the lead did not pick up** → send a different chosen template, and that
   template carries a **"call me again" link** the lead can tap to be re-dialled.

Stripe is the focus, but the design must not assume Stripe (see §3).

---

## 2. The two facts that shape the whole design

**(a) Every piece already exists.** Placing an AI call, sending a template,
minting a call-me-again link, the aftermath fan-out, the anti-double-send ledger —
all of it is built and running for the FUNNEL. This feature is a *second trigger
source* wired into the same machinery, not a new subsystem. Copying the funnel's
shapes is the whole job.

**(b) The natural hook is `PurchaseFulfiller::fulfill()`.** Every payment lane —
Stripe webhook confirm, hosted-link import re-run, PayEx callback, an admin
editing an ACTIVE ledger row, Unreconciled replay — converges there. It already
carries two "side effects that never throw" at the top
(`ReportPurchaseToMetaAction`, `PurchaseSessionAttributor`); the payment
automation is a third one, in exactly that shape.

> ⚠️ `fulfill()` is **re-entered from five lanes** and its own docblock says so.
> The automation MUST be idempotent per purchase, not per lead — see §6.

---

## 3. How a payment actually arrives (verified, with line numbers)

```
Stripe webhook  →  routes/main.php:782  webhooks.stripe.handle
                →  App\Http\Controllers\Webhooks\StripeWebhookController@handle
                →  App\Services\Stripe\WebhookProcessor::process()
                     ├ confirmByReference()        (a checkout we created)
                     ├ confirmByHostedLink()       (a Stripe-hosted payment link)  ← ours
                     └ recordHostedLinkPayment()   :323
                          ├ resolveBuyer()          :584  → Lead (+ phone)
                          ├ PurchaseHistoryRepository::create()
                          └ PurchaseFulfiller::fulfill()   ← ★ HOOK HERE
```

**`PaymentLink` (`src/Payment/PaymentLink.php`)** — the admin-visible payment item.
Real row for the owner's example:

| column | value |
|---|---|
| `id` / `uuid` | 2 / `f06256b6-…` |
| `provider` | `stripe` |
| `external_id` | `plink_1TpqV4EWKNIsKNuKEeFfI0E3` |
| `external_url` | `https://buy.stripe.com/…` |
| `payable_type` / `payable_id` | `Src\Property\Project` / 16 |
| `title` | `Binastra Cochrane` |
| `amount` / `currency` | `100.00` / `MYR` |
| `status` | 1 (active) |

**`PurchaseHistory` (`src/Payment/PurchaseHistory.php`)** — the paid-money ledger.
Columns the automation needs: `lead_id`, `payment_link_id`, `title`,
`total_amount`, `currency`, `payment_reference`, `provider_payment_id`,
`purchased_at`, `status`, `payable_type`, `payable_id`.
There are already real Binastra Cochrane rows (#367, #368, #369).

### Where the phone comes from
`WebhookProcessor::resolveBuyer()` (:584) takes `email` / `phone` / `name` off the
Stripe payload, runs them through `LeadRepository::selfTypedPhoneForGate()` and
`firstOrCreateForIdentity()`, then returns a `Lead`.

**The automation must NOT read the phone from the Stripe payload.** Read it from
the CRM: `$purchase->lead->user->profile->phone` — the same source
`SendAiCallFollowUp` uses. Reasons:
- The identity gate deliberately **drops** a phone that already belongs to another
  email-holding account (that is why a lead can exist with `phone = null`), and
  files a merge request instead. Bypassing it re-introduces the duplicate the gate
  exists to prevent.
- `adoptGatewayDetails()` only overwrites the CRM phone when an admin ticked it on
  the review screen. The CRM value is the deliberate one.

**Consequence:** `lead_id` can be `null` and `phone` can be `null` on a perfectly
valid purchase. Both are SKIP cases with a recorded reason, never a crash.

---

## 4. What to reuse (do not rebuild any of this)

### 4.1 Sending a WhatsApp template
**`app/Jobs/Automation/SendAiCallFollowUp.php`** is the closest working reference —
read it in full. It shows the whole correct shape:
- resolve `Lead` → `user.profile.phone`, return quietly if either is missing;
- pick the channel: first `WhatsappChannel` with `PROVIDER_CLOUD_API` +
  `STATUS_CONNECTED`;
- **persist the message row BEFORE sending** (contact → conversation →
  `WhatsappRepository::createOutbound()` with `TYPE_TEMPLATE`), then
  `recordSendResult($message, $sent)` after, to capture the wamid — this is what
  makes Meta's delivered/read receipts land on the row and puts the message in the
  lead's inbox thread;
- stamp idempotency **first** after a successful send, and treat the wamid write as
  best-effort — a bookkeeping failure must never cause a duplicate message.

⚠️ **`CloudApiDriver::sendTemplate()` writes nothing to the database.** It is pure
transport and returns a `SentMessage` DTO. Calling it without the persistence
above is how these sends became untrackable in the first place.

### 4.2 Placing the AI call
**`app/Actions/PlaceAiVoiceCall.php`** — THE one entry point. Signature:

```php
$call = app(PlaceAiVoiceCall::class)->run(
    $profileOrSlug,          // AiCallProfile|string
    $rawPhone,               // any format, normalised to E.164 inside
    ['lead_name' => $name],  // dynamic variables for the prompt
    $lead->id,
    $ignoreQuietHours = false // TRUE ONLY for an admin dialling their own handset
);
```
Never throws. Returns an `AiVoiceCall` row; a refusal comes back as
`STATUS_FAILED` with `disconnection_reason` in `AiVoiceCall::REFUSAL_REASONS`
(`quiet_hours`, `daily_budget`, `blocked_number`, `profile_not_callable`, …).
Every guard — the call window, the daily budget, the blocklist — lives inside it,
so a new caller gets them for free and must not re-implement them.

**Manual "Call now" precedent:** `AiProfilesController::testCall()` (:400) — read
it for the flash-message and refusal-reporting shape.

### 4.3 The call's aftermath (post-call + no-answer templates)
**`src/VoiceAgent/Services/AiCallAftermath.php`** — the single owner of what
happens because a call reached its outcome. Two arms, called by the Retell
webhook, the reconcile sweep AND the backfill command:

- **`settled($row)`** → the call ended. Fires the MISSED follow-up when
  `AiVoiceCall::qualifiesForMissedFollowUp()` is true (no-answer / busy / voicemail,
  plus genuinely-dialled FAILED rows; **never** our own refusal rows).
- **`analyzed($row)`** → analysis arrived. Posts the 🤖 Discussion comment and
  fires the THANKS follow-up — but only after checking the lead actually SPOKE
  (`turns()->where('role', ROLE_USER)->doesntExist()`), because Retell runs its
  analysis on pickup-and-hang-up calls too and will happily invent extracted
  facts from the agent's own words.

**This is where the payment-item's own "after call" / "no answer" templates must
hook in.** Do NOT add a second webhook listener; that is the exact bug this class
was created to fix (an entire morning of calls settled by the reconcile sweep
produced no comments and no follow-ups because the side effects lived only in the
webhook controller).

### 4.4 The "call me again" link
**`app/Http/Controllers/Main/AiCallbackController.php`**
- `mintToken(Lead $lead, string $profileSlug): string` (:38) — a `Crypt`
  URL-safe payload carrying lead id + profile slug + a 7-day expiry.
- `show()` (:56) — decodes, dedupes (one call per lead per 10 min), calls
  `PlaceAiVoiceCall`, and renders an Inertia page with states
  `calling` / `already_calling` / `quiet_hours` / `failed` / `invalid`.
- Route: `GET ai-callback/{token}`, throttled `6,1`.

The token already carries the profile slug, so a payment-item template can mint a
link that re-dials **that item's** brain with no new plumbing. Put it in the
template's **URL button** as the dynamic suffix — see how `SendAiCallFollowUp`
does it for `ai_caller_missed_v2`.

### 4.5 The admin-configurable automation pattern (copy this shape)
The funnel already solves "let an admin configure per-entity automation":

| Concern | Funnel's answer | File |
|---|---|---|
| The rule | `FunnelAutomationMessage` — `MEDIUM_*`, `TRIGGER_*`, `is_active`, `offset_minutes`/`offset_seconds`, `aiCallProfile()` belongsTo | `src/Event/FunnelAutomationMessage.php` |
| WhatsApp-specific rule | `FunnelWhatsappMessage` | `src/Event/FunnelWhatsappMessage.php` |
| The anti-double-send ledger | `FunnelAutomationSend` — `STATUS_QUEUED/SENT/SKIPPED/FAILED` + a `SKIP_*` reason vocabulary (`no_phone`, `missing_config`, `opted_out`, `quiet_hours`…) with a **unique key on (message, lead, event)** | `src/Event/FunnelAutomationSend.php` |
| The dispatcher | `DispatchFunnelWelcomeAction` — chooses scope, creates the ledger row, dispatches the job with a delay | `app/Actions/DispatchFunnelWelcomeAction.php` |
| The send job | `SendFunnelWhatsAppMessage`, `SendFunnelAiCall` | `app/Jobs/Automation/`, `app/Jobs/Whatsapp/` |
| Admin UI | rule rows + a form modal with a template picker | `resources/js/Pages/Manage/Events/Funnels/Partials/Automation/RuleRow.vue`, `.../WhatsappRuleFormModal.vue` |

**The ledger is the important part.** A `SKIPPED` row with a reason is how the
funnel makes "nothing happened" visible instead of silent, and how the admin can
see WHY. Reproduce that; do not settle for a boolean.

---

## 5. Proposed shape (a suggestion, not a mandate)

### 5.1 Data
One rule table + one send ledger, mirroring the funnel:

```
payment_link_automations
  id, uuid
  payment_link_id        (indexed, no FK — GUIDELINES §7)
  trigger                unsignedInteger  → TRIGGER_ON_PAID / AFTER_AI_CALL / AI_CALL_NO_ANSWER
  medium                 unsignedInteger  → MEDIUM_WHATSAPP / MEDIUM_AI_CALL
  whatsapp_template_id   nullable (or template NAME — see the open question in §7)
  ai_call_profile_id     nullable, indexed
  is_active              boolean
  offset_seconds         nullable   (delay before firing)
  created_by/updated_by/deleted_by, timestamps, softDeletes

payment_link_automation_sends
  id, uuid
  payment_link_automation_id (indexed)
  purchase_history_id        (indexed)  ← THE idempotency key, not lead_id
  lead_id                    (indexed, nullable)
  ai_voice_call_id           nullable, indexed
  status                     QUEUED / SENT / SKIPPED / FAILED
  skip_reason                nullable string
  whatsapp_message_id        nullable   (links the real whatsapp_messages row)
  sent_at, timestamps
  UNIQUE (payment_link_automation_id, purchase_history_id)
```

Constants + a `STATUSES`/`TRIGGERS`/`MEDIUMS` metadata array on the models per
GUIDELINES §4.1–4.2. Writes through a Repository inside `DB::transaction`
(GUIDELINES §2).

### 5.2 Flow
```
PurchaseFulfiller::fulfill()
  └─ app(DispatchPaymentAutomationAction::class)->execute($purchase)   ← new, never throws
       ├ find active automations for $purchase->payment_link_id
       ├ TRIGGER_ON_PAID + MEDIUM_WHATSAPP
       │    → createSend(...) → SendPaymentAutomationWhatsApp::dispatch()->delay(offset)
       └ TRIGGER_ON_PAID + MEDIUM_AI_CALL (if the owner later wants auto-dial)
            → SendPaymentAutomationAiCall::dispatch()

Admin presses "Call now" on a paid lead
  └─ POST manage.payment.payment-links.call  → PlaceAiVoiceCall->run(profile, phone, vars, leadId)
       → the AiVoiceCall row carries meta.payment_link_id + meta.purchase_history_id

Retell webhook / reconcile / backfill
  └─ AiCallAftermath::settled()  → if the call carries meta.payment_link_id,
     │                              fire that item's NO-ANSWER automation
     └ AiCallAftermath::analyzed() → …and its AFTER-CALL automation
```

**Stamping `payment_link_id` / `purchase_history_id` into `AiVoiceCall.meta` at
placement time is what lets the aftermath know which payment item's templates to
use.** `AiVoiceCallRepository::createOutbound()` already whitelists
`ai_voice_call.meta`, and `PlaceAiVoiceCall` currently does not pass it — so
either extend the action's signature or write the meta immediately after.
**Decide this deliberately and note it**; a second `meta` write outside the action
is the kind of thing that drifts.

### 5.3 Admin UI
Follow GUIDELINES §14. The list page `/manage/payment-links` already exists
(`Pages/Manage/Payment/Links/Index.vue` + `PaymentLinkFormModal.vue`). Options:
- an **Automation** row action opening a modal listing that item's rules
  (closest to `WhatsappRuleFormModal.vue`), or
- a Show page per payment link with a ShowTabs strip (§14 "Detail (Show) page").

The owner did not specify. **Ask before building the heavier one.**

---

## 6. Traps — every one of these has already caused a live incident here

1. **`fulfill()` is re-entered from five lanes.** Key idempotency on
   `purchase_history_id`, never on `lead_id` — a lead can legitimately buy twice.
2. **Never let a bookkeeping failure cause a duplicate message.** Stamp the
   send/skip FIRST after a successful provider call; the wamid write is
   best-effort. (`SendAiCallFollowUp` was fixed for exactly this.)
3. **The lead may have no phone.** The Stripe identity gate drops a phone that
   belongs to another email-holding account. `SKIPPED / no_phone`.
4. **`lead_id` may be null** on a valid purchase. `SKIPPED / no_lead`.
5. **A refunded purchase must grant nothing.** `recordHostedLinkPayment` records
   refunds as `STATUS_INACTIVE` and returns before `fulfill()`. Do not send a
   congratulations WhatsApp for money that came back.
6. **Never send a "we tried to call you" message for a refusal row.** Use
   `AiVoiceCall::qualifiesForMissedFollowUp()`; our own guards (quiet hours,
   budget, blocklist) never rang a phone.
7. **Do not add a second Retell webhook listener.** Go through `AiCallAftermath`,
   or reconcile-settled calls will silently produce nothing.
8. **`PurchaseFulfiller::fulfill()` must never throw because of this feature.**
   Wrap the dispatch in try/catch + `Log::warning`, like its two existing side
   effects. A customer must never lose what they paid for because WhatsApp was
   down.
9. **Templates are per-WABA and the WABA is SHARED with production.** Submitting
   or deleting a template from dev affects production. Two templates were
   submitted on 2026-08-31 for this feature and are PENDING review:
   - `payment_received_v1` (zh_CN, UTILITY) — `{{1}}` name, `{{2}}` item,
     `{{3}}` amount, `{{4}}` reference. No buttons.
   - `ai_call_consent_form_v1` (zh_CN, UTILITY) — `{{1}}` name, `{{2}}` form URL
     (`https://investhink.ai/fpa/consent-public`), then asks the lead to reply
     with a preferred Zoom date/time.
   The third (`payment_ai_call_missed_v1`) is now submitted too — see §7d.
   Do NOT reuse the funnel's `ai_caller_missed_v2`: its wording is bound to
   webinar registration, not to a payment.
10. **Meta re-categorises.** Anything with invitation/promotional phrasing becomes
    MARKETING even if submitted as UTILITY (`ai_caller_missed_v2` did). Keep
    payment templates strictly transactional.
11. **`AiCallProfile` is picked by SLUG by every caller** — that is the modularity
    contract. Store `ai_call_profile_id` for the relation but resolve through the
    model, and never hardcode a slug (a dead hardcoded `'bootcamp-welcome'`
    fallback silently broke the whole call-again button once).

---

## 7. Decisions made by the owner (2026-08-31)

**Q1 — Auto-dial? → NO. Manual button only, for now.**
The owner's real intent: the AI call should happen **after a Zoom webinar session
ENDS** — the Zoom webhook already tells the CRM when that is (see
`app/Http/Controllers/Webhooks/ZoomWebhookController.php` and the event page
`/manage/events/{uuid}`). That automatic trigger is judged too complex for this
round, so **v1 ships a manual "Call now" button only**: after a webinar the owner
opens the payment item and dials the buyers by hand.

> **Design so this is not painted into a corner.** The manual button and a future
> `TRIGGER_SESSION_ENDED` must be two callers of the SAME action, not two code
> paths. Build `PlacePaymentAiCall` (or equivalent) taking a `PurchaseHistory` and
> returning the `AiVoiceCall`, so the button calls it today and a Zoom-webhook
> listener can call it later with no rework. Keep `TRIGGER_*` on the automation
> table open-ended for it.

**Q2 — UI → a Show page per payment item, with tabs (GUIDELINES §14).**
`GET /manage/payment-links/{id}` → `Pages/Manage/Payment/Links/Show.vue`, with
`PageHeader` (smart back link via `ResolvesBackUrl`) + identity card + `ShowTabs`.
Suggested tabs: **Buyers** (the `PurchaseHistory` rows, with the Call-now action)
and **Automation** (the rules). Route must be declared AFTER any literal GET
segment (`adoptable`), or a bare word is swallowed as an `{id}`.
Reference implementation named in GUIDELINES §14: `Pages/Manage/Membership/Show.vue`.

**Q3 — Template selection → pick from the synced `whatsapp_templates` table, and
offer ONLY `STATUS_APPROVED` rows.**
Store the reference so it survives a re-sync, and show the admin the rendered body
so they can see the variables. `WhatsappTemplate::renderBody($components, $params)`
(:297) already substitutes `{{n}}` for a preview. Filter to the CONNECTED Cloud
channel's templates — a template belongs to a channel/WABA.

---

## 7b. The remaining decisions (all answered 2026-08-31)

**Q4 — Buyers tab scope → list ALL buyers of the payment item, newest first.**
Show the session when known, and provide a **"not yet called"** filter — that is
the working set after a webinar. Do NOT scope the list to a session: only 3 of 24
Binastra Cochrane purchases carry an `event_id` (attribution requires a CTA tap or
a session ATTENDED before paying), so a session filter would hide most buyers.

**Q5 — Bulk calling → NOT in v1. One at a time.**
If bulk is added later it must be a **queued, paced job**, never a `foreach` over
buyers: `services.retell.daily_budget_usd` ($15 default) refuses mid-run, and the
voice handbook is explicit that telephony reputation systems trigger on *patterns*
(call volume, short durations), so any bulk caller must queue and pace its dials.

**Q6 — Variable mapping → FIXED IN CODE per trigger.**
The Show page must **display what each variable will be filled with**, so the
admin can see the mapping without reading code. No admin-editable mapping in v1.

**Q7 — Payment providers → NO restriction.**
The automation belongs to the payment ITEM, not to a gateway. The
`PurchaseFulfiller` hook catches Stripe, PayEx and in-app checkouts alike;
restricting to Stripe would be extra code for less function.

**Q8 — Multiple rules per trigger → allowed** (`offset_seconds` supports it).

---

## 7c. Two requirements added by the owner — both are load-bearing

### (A) Per-lead delivery status must be visible: sent / delivered / read / failed

The Buyers tab must show, per lead, how far each automated WhatsApp actually got.
**This is already solved once — copy it, do not reinvent it.**

`Src\VoiceAgent\Support\AiCallPresenter` is the working reference:
- `deliveryMap(iterable $calls): array` — resolves EVERY linked message's status in
  **one** query (`whereIn('id', $ids)`), keyed by message id. Call it once per page
  of rows and hand the map to each row; never query per row.
- `followup($call, $deliveryMap)` — returns `{kind, label, color, is_send, sent_at,
  delivery: {status, label, color}|null}`.
- The link is a message id stamped on the domain row's `meta`
  (`meta.followup_message_id`), written by `SendAiCallFollowUp` right after
  `recordSendResult()`.

The ladder itself is `Src\Whatsapp\WhatsappMessage::STATUSES` — QUEUED 1, SENT 2,
DELIVERED 3, READ 4, FAILED 5 — advanced by `WhatsappRepository::updateStatus()`
(:317) when Meta's status webhook arrives, matched on
`(channel_id, provider_message_id)`. The front-end tick vocabulary is
`resources/js/utils/messageStatus.js` (`tickFor()`); the AI Calls index renders it
at `Pages/Manage/Calls/AiCalls/Index.vue` (the `#cell-followup` slot) — copy that
cell.

**So the send ledger row (§5.1) must carry `whatsapp_message_id`**, and the Buyers
tab presenter must batch-resolve it exactly like `deliveryMap()`.

> ⚠️ Delivery status only advances in the environment that RECEIVES Meta's status
> webhooks (production). On dev a send honestly stays "Sent" forever — that is not
> a bug, and the UI should not imply the message failed.

### (B) Launch cutoff — existing purchasers must NOT be auto-contacted

When this ships there are already ~24 Binastra Cochrane purchases (and hundreds
across other items). **None of them may be auto-called or auto-messaged.** Only
purchases made AFTER the automation is switched on fire automatically. The admin
must still be able to press Send / Call manually on an old buyer.

**Recommended mechanism: an `effective_from` timestamp on the automation rule**
(default `now()` at creation). The dispatcher fires only when
`$purchase->purchased_at >= $automation->effective_from`. Older purchases render in
the Buyers tab as *"before this automation started"* with the manual buttons live.

Why this and not the alternative: pre-stamping every historical purchase with a
SKIPPED ledger row would mean writing hundreds of rows at rule-creation time, and
it answers "was this skipped?" but not "why" a year later. A timestamp is one
column, is self-documenting, and survives the rule being paused and resumed.

> **This exact class of mistake has already happened here.** On 2026-08-23 an
> AI-call follow-up backfill was about to WhatsApp a whole morning of leads a
> second time; it was only avoided by hand-stamping the historical rows first.
> Treat "does this reach a real customer twice" as the first question of every
> dispatch path, and make the manual buttons confirm before firing.

---

## 7d. The three WhatsApp templates (all submitted, all PENDING review)

Submitted to the SHARED production WABA `2094456611497681` on channel #6
(`+60103368411`) on 2026-08-31. All `zh_CN`, all submitted as `UTILITY`.

| Template | Meta id | Variables | Buttons |
|---|---|---|---|
| `payment_received_v1` | 2491637494646033 | 1 name · 2 item · 3 amount · 4 reference | none |
| `ai_call_consent_form_v1` | 29217775257810591 | 1 name · 2 form URL | none |
| `payment_ai_call_missed_v1` | 1133846739209170 | 1 name · 2 item | URL `立即回电给我` → `https://app.propertylab.com.my/ai-callback/{{1}}` |

**`payment_ai_call_missed_v1`'s button is the call-again mechanism the owner
asked for.** Its dynamic suffix must be filled with
`AiCallbackController::mintToken($lead, $profileSlug)` at send time — see
`SendAiCallFollowUp`'s `'buttons' => [['sub_type' => 'url', 'index' => 0, 'text' => …]]`
for the exact param shape. The token already encodes the profile slug, so tapping
re-dials the payment item's own brain. Both `app.propertylab.com.my` and
`propertylabglobal.com` serve `/ai-callback/{token}` (both verified HTTP 200);
production was chosen.

`ai_call_consent_form_v1`'s `{{2}}` is a variable rather than a hardcoded link on
purpose — a per-lead tracked URL can be swapped in later without re-submitting.

⚠️ Meta may still re-categorise any of these to MARKETING after review, as it did
to `ai_caller_missed_v2`. Check the final category before relying on the utility
pricing, and never add promotional phrasing to these bodies.

## 8. Files the implementing session must read

**Payment side**
- `app/Services/Payment/PurchaseFulfiller.php` — ★ the hook, and the "never throws" pattern
- `app/Services/Stripe/WebhookProcessor.php` — esp. `recordHostedLinkPayment()` :323, `resolveBuyer()` :584
- `src/Payment/PaymentLink.php`, `src/Payment/PurchaseHistory.php`
- `src/Payment/Repositories/PaymentLinkRepository.php`, `PurchaseHistoryRepository.php`
- `app/Http/Controllers/Manage/Payment/PaymentLinksController.php`
- `resources/js/Pages/Manage/Payment/Links/Index.vue` + `Partials/PaymentLinkFormModal.vue`
- `routes/web.php` — the `payment-links` group (~line 1045)

**AI caller side**
- `app/Actions/PlaceAiVoiceCall.php` — ★ the one entry point + every guard
- `src/VoiceAgent/Services/AiCallAftermath.php` — ★ where post-call effects belong
- `src/VoiceAgent/AiVoiceCall.php` — `STATUSES`, `REFUSAL_REASONS`, `qualifiesForMissedFollowUp()`
- `src/VoiceAgent/AiCallProfile.php` + `Repositories/AiCallProfileRepository.php`
- `src/VoiceAgent/Repositories/AiVoiceCallRepository.php` — `createOutbound()` field whitelist, `mergeMeta()`
- `app/Http/Controllers/Manage/Calls/AiProfilesController.php` — `testCall()` :400 (manual-call precedent)
- `app/Http/Controllers/Main/AiCallbackController.php` — `mintToken()` :38
- `app/Http/Controllers/Webhooks/RetellWebhookController.php`
- `docs/modules_handbook/shared/voice-agent/readMe.md` — ★ the whole module's contract

**WhatsApp side**
- `app/Jobs/Automation/SendAiCallFollowUp.php` — ★ the reference implementation for a template send
- `src/Whatsapp/Drivers/CloudApiDriver.php` — `sendTemplate()` :260, `createTemplate()` :1055, `buildTemplateComponents()` :366
- `src/Whatsapp/Repositories/WhatsappRepository.php` — `createOutbound()` :1423, `recordSendResult()` :1468, `updateStatus()` :317
- `src/Whatsapp/WhatsappMessage.php`, `src/Whatsapp/WhatsappTemplate.php`, `src/Whatsapp/WhatsappChannel.php`
- `docs/modules_handbook/shared/media/readMe.md` is NOT needed; there is no media here

**Pattern to copy (admin-configurable automation)**
- `src/Event/FunnelAutomationMessage.php`, `src/Event/FunnelAutomationSend.php`
- `src/Event/FunnelWhatsappMessage.php`
- `app/Actions/DispatchFunnelWelcomeAction.php`
- `app/Jobs/Automation/SendFunnelAiCall.php`, `app/Jobs/Whatsapp/SendFunnelWhatsAppMessage.php`
- `resources/js/Pages/Manage/Events/Funnels/Partials/WhatsappRuleFormModal.vue`
- `resources/js/Pages/Manage/Events/Funnels/Partials/Automation/RuleRow.vue`

**For the FUTURE session-ended trigger (do not build now, but do not preclude it)**
- `app/Http/Controllers/Webhooks/ZoomWebhookController.php` — where "the webinar ended" arrives
- `app/Http/Controllers/Manage/Events/EventsController.php` — the event Show page + registrations
- `app/Services/Marketing/PurchaseSessionAttributor.php` — how a purchase gets its `event_id`

**Rules**
- `CLAUDE.md`, `GUIDELINES.md` (§2 repositories, §4 models, §7 migrations, §9 query
  requests, §14 admin index/Show pattern)

---

## 9. Verification the implementing session should do

- `php artisan tinker` against the real rows: `PaymentLink::find(2)` and
  `PurchaseHistory::where('payment_link_id', 2)->get()` — three real Binastra
  Cochrane purchases exist to test against.
- Re-run `PurchaseFulfiller::fulfill()` on an already-fulfilled purchase and prove
  **no second WhatsApp** is sent (this is trap #1, and it is the one most likely
  to reach a real customer).
- Place a manual call, let it go unanswered, and prove the no-answer template
  fires from `AiCallAftermath::settled()` — then re-run
  `voice-agent:reconcile` and prove it does **not** fire twice.
- Check `docs/modules_handbook/` — a new module/submodule needs its own
  `readMe.md` per `docs/modules_handbook/README.md`.
