# Payment Items · Automation (WhatsApp + AI call per payment item)

**Portal:** Manage · **Home:** a payment item's own page, `/manage/payment-links/{uuid}` (`manage.payment.payment-links.show`) — reached from the [Payment Items](/docs/modules_handbook/manage/payments/payment-links/readMe.md) list (the title, or the 👁 row action) · **Tabs:** **Buyers** (every purchase, with what each rule did per buyer, the item's AI call, and the manual Call now / Send now) · **Automation** (the item's AI caller brain + its rules) · **Routes:** `manage.payment.payment-links.{show,ai-profile,automations.*,buyers.*}` · **Built:** 2026-09-01 (`4b2498f0c`), spec in `docs/temp/payment-aicall-whatsapp-planning.md` · **Live** on *Binastra Cochrane* since 2026-09-01 · **Re-audited 2026-09-15** against the commits that landed after it (the AE table merge, the "nobody spoke" guard, the suite resolver) — two regressions found and fixed, both marked ⚠️ below. · **Extended 2026-09-15** for *Plus Membership CLIENT BONUS TV*: an optional WhatsApp link `{{5}}` on the receipt, the after-call `{{2}}` became **each buyer's own consent link**, and Call now tells the brain which webinar the buyer came from.

## What it does

A payment item (e.g. *Binastra Cochrane — Project fee MYR 100*) carries **admin-configurable automation** for the people who buy it:

1. **When they pay** → a chosen WhatsApp template (a receipt: name, item, amount, gateway reference, and — optionally — a WhatsApp link typed on the rule).
2. **AI caller brain** → the item names an [AI call profile](/docs/modules_handbook/shared/voice-agent/readMe.md); an admin presses **Call now** on a buyer (v1 is manual only — the automatic *after the webinar ends* dial is a later trigger, see *Deferred*).
3. **After the AI call** → a chosen template carrying **the buyer's own consent form link** (`/consent/{token}`) — sent **only when the buyer actually spoke**.
4. **AI call not answered** → a different template whose **link button is a "call me again" link** that re-dials this item's brain.

Nothing here is a new subsystem: it is a **second trigger source** wired into the machinery the funnel already runs — [`PlaceAiVoiceCall`](/app/Actions/PlaceAiVoiceCall.php), [`AiCallAftermath`](/src/VoiceAgent/Services/AiCallAftermath.php), the Cloud template send pipeline, the `/ai-callback/{token}` link — with its own rule table + send ledger mirroring the funnel's.

**Two invariants, both learned from live incidents elsewhere in this CRM:**

- **Idempotent per PURCHASE (or per CALL), never per lead.** `PurchaseFulfiller::fulfill()` is re-entered from five lanes (webhook confirm, import re-runs, PayEx, an admin editing an ACTIVE ledger row, Unreconciled replay); a lead can legitimately buy twice. The ledger's unique `claim_key` — `"{rule}:purchase:{id}"` / `"{rule}:call:{id}"` — is the guard, at the database.
- **Existing buyers are never auto-contacted.** Every rule carries `effective_from` (stamped at creation, never taken from a form): only a purchase — or a call — made **at or after** that instant fires automatically. Older buyers show *"Before this rule started"* on the Buyers tab with a manual **Send now**; both manual buttons confirm before firing.

## How it works

### Data model

- **`payment_links.ai_call_profile_id`** (migration `2026_09_01_100002`) — the item's brain. On the ITEM, not on a rule: one item, one manual button. `PaymentLink::aiCallProfile()`; set from the Automation tab via `PUT {id}/ai-profile` → `PaymentLinkRepository::update()` (a plain field write rides the existing method).
- **`payment_link_automations`** ([PaymentLinkAutomation](/src/Payment/PaymentLinkAutomation.php), key model) — one rule: `trigger` (`TRIGGER_ON_PAID = 1` / `TRIGGER_AFTER_AI_CALL = 2` / `TRIGGER_AI_CALL_NO_ANSWER = 3`; **4 is reserved** for the session-ended dial, **`medium` 2 is reserved** for an automatic AI-call rule — neither declared until it can fire), `whatsapp_channel_id` + `template_name` + `template_language` (the same template reference the funnel rules keep — `whatsapp_templates` is unique on channel + name + language), `link_url` (the WhatsApp link a *When they pay* template carries in its optional `{{5}}` — a field so it changes without a deploy; kept for that trigger only and nulled on save for every other), `offset_seconds` (delay, ≤ 7 days), `is_active`, `effective_from`.
- **`payment_link_automation_sends`** ([PaymentLinkAutomationSend](/src/Payment/PaymentLinkAutomationSend.php), append-only ledger) — one row per (rule, purchase) or (rule, call): `claim_key` (unique), `lead_id`, `ai_voice_call_id`, **`whatsapp_message_id`** (the real outbound row — Meta's delivered/read receipts advance ITS ladder), `to`, `status` (Queued / Sent / Skipped / Failed) + `skip_reason`, `sent_at`.
  - `SKIP_*`: `no_lead` (the gateway's buyer could not be attributed) · `no_phone` (the identity gate deliberately withholds a checkout phone that belongs to another account) · `missing_config` · `template_not_approved` (Meta paused it since) · `opted_out` (STOP / blocked) · `refunded` (money came back during the delay) · `lead_silent` (after-call: the buyer never spoke) · `capped` (no-answer: one call-me-again per buyer per day).
  - `outcomeFor(ledgerStatus, messageStatus)` folds the ledger with the message's delivery ladder → the `OUTCOMES` vocabulary the Buyers tab renders (pending / queued / sent / delivered / read / failed / skipped).

### The variable mapping is FIXED IN CODE

[`Src\Payment\Support\PaymentAutomationVariables`](/src/Payment/Support/PaymentAutomationVariables.php) is the ONE place: `catalogue($trigger)` is what the Automation tab displays and what `StoreRequest` validates against; `bodyParameters()` is what the send fills in. Two lists would drift, and a drifted mapping sends a customer the wrong fact with nothing failing.

| Trigger | `{{1}}` | `{{2}}` | `{{3}}` | `{{4}}` | `{{5}}` | Link button |
|---|---|---|---|---|---|---|
| When they pay | buyer name (`朋友` when blank) | item title (frozen on the purchase) | amount, e.g. `MYR 100.00` — **already carries the currency** | `provider_payment_id` (`pi_…`), falling back to `payment_reference` | *optional* — the rule's **WhatsApp link** (`walink`) | none |
| After the AI call | buyer name | **the buyer's own consent link** (`consent_link`) | | | | none |
| AI call not answered | buyer name | item title | | | | **exactly one**, its `{{1}}` suffix = `AiCallbackController::mintToken($lead, $profileSlug, ['purchase' => $id])` |

- **An OPTIONAL variable makes the count a range.** The catalogue marks `walink` `optional`, so *When they pay* accepts a 4- **or** 5-variable template (`requiredCount()`..`totalCount()`). The send fills exactly as many as the APPROVED template has (`bodyParameters(…, $templateVariables)` slices the catalogue) — Meta rejects a send whose parameter count differs, so a rule still on `payment_received_v1` keeps working while `v2` is under review, even if a link is already typed on it. `StoreRequest` demands the link only when the picked template `reaches()` its `{{5}}`.
- ⚠️ **Never write `RM` before `{{3}}`.** The amount already reads `MYR 100.00`; `payment_received_v1` wrote `金额：RM {{3}}` and every buyer got **"RM MYR 100.00"**. `payment_received_v2` drops it.
- **The consent link is minted at SEND time, never typed.** [`SendPaymentAutomationWhatsApp::consentLinkFor()`](/app/Jobs/Automation/SendPaymentAutomationWhatsApp.php), after every skip (a message that never goes out never mints an invitation): (1) the lead's latest **SIGNED** consent → that link, which opens the *already signed* page (owner decision 2026-09-15: signed buyers still get the message for its other ask, and a fresh invitation would let them sign twice); (2) else [`LeadConsentRepository::generateLink()`](/src/Lead/Repositories/LeadConsentRepository.php) — reuses an invitation still outstanding, or mints one (48-char token, 14 days) — the same writer as the agents' *Generate consent link* button, so both hand the same person the same link. Should it ever throw `ConsentContactMissingException`, the row is **`SKIPPED / consent_unavailable`** (unreachable today: the send already required a phone). The old per-rule consent URL is gone — every after-call rule now sends the per-buyer link; a stale `link_url` left on one is simply not shown or used.

The admin picks **any APPROVED template on a Cloud channel whose shape fits** — `StoreRequest::withValidator` checks the distinct `{{n}}` count, refuses a header variable, and demands exactly one dynamic URL button for the no-answer trigger and none otherwise. The modal greys out templates that do not fit, with the reason.

### Sending

- **On paid:** [`PurchaseFulfiller::fulfill()`](/app/Services/Payment/PurchaseFulfiller.php) → [`DispatchPaymentAutomationAction::execute()`](/app/Actions/Payment/DispatchPaymentAutomationAction.php) — the third "side effect that never throws" beside the Meta report and the session attributor. For each active ON_PAID rule whose `effective_from` ≤ `purchased_at`: `claimSend()` (a replay gets null) → [`SendPaymentAutomationWhatsApp`](/app/Jobs/Automation/SendPaymentAutomationWhatsApp.php) with the rule's delay.
- **The job** re-checks everything at the LAST moment (rule live, channel Cloud + active, purchase still ACTIVE, buyer + phone from the **CRM** — never the gateway payload — template still APPROVED, the no-answer daily cap, contact not blocked / not opted out of **the template's OWN category** — `WhatsappConsent`'s categories mirror `WhatsappTemplate`'s 1:1, and **Meta re-categorises after approving**: it has already moved `ai_call_consent_form_v1` and `payment_ai_call_missed_v1` to MARKETING, so a hardcoded UTILITY check would send a marketing message to somebody who opted out of marketing), fills the variables, then lands the template in the normal outbound pipeline: contact → conversation → `WhatsappRepository::createOutbound()` (TYPE_TEMPLATE, `meta.source = payment_automation`, `meta.template = {name, language, parameters, buttons, preview}`) → `SendWhatsAppMessage`. The ledger is SENT the moment the row exists; from there the message row's own ladder is the truth, and `SendWhatsAppMessage` only ever delivers a row still QUEUED. A call-triggered send also stamps the call's `meta.followup_sent` (`payment_after_call` / `payment_missed`) + `followup_message_id`, so the AI Calls page and the lead's AI Caller tab show its delivery too.
- **Call now:** `POST {id}/buyers/{purchaseId}/call` → [`PlacePaymentAiCall`](/app/Actions/Payment/PlacePaymentAiCall.php) — `refusalFor()` (no brain / not callable / refunded / no lead / no phone / called in the last 10 min, in the admin's words; the Buyers tab disables the button with the same reason) then `run()` → `PlaceAiVoiceCall::run(profile, phone, vars, leadId, false, meta)` with **`meta.payment_link_id` + `meta.purchase_history_id`**. The dynamic variables are `lead_name`, `payment_item`, `payment_amount`, and — when the purchase is credited to a session (`purchase_histories.event_id`) — **`webinar_title`** + **`webinar_date`** (`j F Y`, the funnel callers' shape); with no session both keys are absent, so a prompt must carry its own fallback (`{{webinar_title}}` unset reads as the literal braces otherwise). Every guard (quiet hours, daily budget, blocklist) stays inside `PlaceAiVoiceCall`; a refusal comes back as a FAILED row and is flashed. ⚠️ The 9am–9pm window is a **toggle that is OFF by default** (`RETELL_QUIET_HOURS`, default `false` since 2026-08-22 — the owner's "call 100% now" switch): with it off, "Call now" after a 10pm webinar rings the buyer immediately; with `RETELL_QUIET_HOURS=true` the same press is refused (`quiet_hours`) until 9am. Check the box's `.env` before assuming either.
- **The aftermath:** `AiCallAftermath::settled()` / `analyzed()` **branch on `AiVoiceCall::isPaymentCall()`** (`meta.payment_link_id`): a payment call takes the item's NO_ANSWER rules (`callMissed()`, per-call claim — a reconcile re-settle is a no-op, a genuine re-dial earns its own message) and AFTER_AI_CALL rules (`afterCall()`) **instead of** the funnel's `ai_caller_thanks_v2` / `ai_caller_missed_v2`, which are worded for a webinar registration. The Discussion comment and the flagged-number alert apply to both alike.
  - ⚠️ **`afterCall()` is called from BOTH arms of `analyzed()`, and that is load-bearing.** The host's own "the lead never spoke" guard (`20cf66f34`, 2026-08-29 — Retell analyses pickup-and-hang-up calls and answers the whole extraction schema from the agent's own words) returns EARLY, before the payment branch. Left at that, a silent call wrote no ledger row at all: the Buyers tab cell read *"Not sent yet"* under a live **Send now** button, so the admin would mail the consent form to somebody who never said a word — the exact outcome the host guard exists to prevent, reached by hand. The silent arm therefore calls `afterCall()` too, and the dispatcher re-asks the turns question itself and records **`SKIPPED / lead_silent`**. Its own check is NOT redundant with the host's; deleting it sends the form on every silent call.
  - The merged Appointment Engine added `ai_voice_calls.lead_spoke` (derived by `RetellCallMapper`) and `AiVoiceCall::analysisIsEvidence()`. Both the host guard and `afterCall()` deliberately still ask the TURNS, so the two can never disagree on a row the mapper has not touched (a reconciled or hand-made one).
- **The call-me-again link:** the token now carries `purchase`; `AiCallbackController::show()` re-dials **through `PlacePaymentAiCall`** for such a token (same guards, same meta — so the re-dial's own aftermath is answered by the item's rules again) and renders `failed` when the purchase can no longer be called; a legacy token without `purchase` dials the plain way, unchanged.
- **Send now:** `POST {id}/buyers/{purchaseId}/send/{automationId}` → `DispatchPaymentAutomationAction::manual()` — claims the (rule, purchase) row for a buyer nothing reached yet, **re-queues** a SKIPPED / FAILED row, and refuses when the row is QUEUED or SENT (never a second copy from a button).

### Frontend

- [`Pages/Manage/Payment/Links/Show.vue`](/resources/js/Pages/Manage/Payment/Links/Show.vue) — `PageHeader` (back link via `ResolvesBackUrl`, Copy / Open / Disable / Edit — Edit reuses the index's `PaymentLinkFormModal`) → identity card (what it sells, collected, locked-to, AI caller, URL) → `ShowTabs`.
- [`Partials/Tabs/BuyersTab.vue`](/resources/js/Pages/Manage/Payment/Links/Partials/Tabs/BuyersTab.vue) — an embedded `DataTable` (newest 500, `paginated=false`) with filter chips **All / Not yet called / Refunded**, one banded column per rule (outcome chip + delivery tick or skip reason + Send now), the AI CALL band (latest call placed FOR this item, its follow-up), and **Call now** (disabled with the refusal reason). Both buttons go through `ConfirmModal`. The shared `PaymentDetailModal` opens a payment.
- [`Partials/Tabs/AutomationTab.vue`](/resources/js/Pages/Manage/Payment/Links/Partials/Tabs/AutomationTab.vue) — the brain picker (callable profiles only) and the rules laid out as the buyer's journey (three steps, each with its fixed mapping line, its rules, and *Add*); rule rows show channel, delay, template state at Meta, `since <effective_from>`, and a lifetime tally. [`Partials/AutomationRuleFormModal.vue`](/resources/js/Pages/Manage/Payment/Links/Partials/AutomationRuleFormModal.vue) — trigger, number, template (approved only, unfit ones greyed with the reason), the mapping panel (optional variables badged), the **WhatsApp link** field (*When they pay*, required once the template reaches `{{5}}`, an amber note when it does not), a note on after-call that `{{2}}` is each buyer's own consent link, delay, active; `TemplatePreview` bubble.
- The index gained a **View** action and a clickable title.

### Payload builders (backend)

- [`App\Http\Controllers\Concerns\BuildsPaymentLinkBuyers`](/app/Http/Controllers/Concerns/BuildsPaymentLinkBuyers.php) — the Buyers rows: sends per (rule, purchase), calls per purchase (`meta->payment_link_id`), both delivery maps — **one query each for the page, never per row**; the "recently called" check is derived from the batch-loaded call (`PlacePaymentAiCall::refusalFor(…, checkRecent: false)`).
- [`Src\Payment\Support\PaymentAutomationPresenter`](/src/Payment/Support/PaymentAutomationPresenter.php) — `deliveryMap()` + `cell()` (the per-rule cell: outcome, delivery, skip reason, `before_start`, `can_send`) — the payment twin of `AiCallPresenter`.

### Deferred / not built

- **Automatic dial after the webinar ends** (`TRIGGER_SESSION_ENDED = 4`, `MEDIUM_AI_CALL = 2`): the Zoom `webinar.ended` webhook already knows the moment ([`ZoomWebhookController::handleWebinarEnded`](/app/Http/Controllers/Webhooks/ZoomWebhookController.php)); a listener would call the same `PlacePaymentAiCall::run()` per buyer — **queued and paced**, never a `foreach` (the daily budget refuses mid-run and telephony reputation triggers on call patterns). Bulk "call everyone" on the Buyers tab is deferred for the same reason.
- **Delivery status only advances on the environment that receives Meta's status webhooks** (production). On dev a send honestly stays *Sent*.
- **Templates are per-WABA and shared with production.** `payment_received_v1`, `ai_call_consent_form_v1`, `payment_ai_call_missed_v1` (all `zh_CN`, submitted as UTILITY on channel `+60103368411`, 2026-08-31) exist on production's `whatsapp_templates`; **`payment_received_v2`** (5 variables, no `RM`, `{{5}}` = the WhatsApp link, submitted 2026-09-15, Meta id `2286495405530966`) replaces v1 on every *When they pay* rule once approved; a dev box pulls them with `php artisan whatsapp:sync-templates`. Never reuse the funnel's `ai_caller_missed_v2` here — its wording is bound to a webinar registration.
  - ⚠️ **All three were approved, and Meta then MOVED TWO of them.** As of 2026-09-15 `payment_received_v1` is UTILITY, while `ai_call_consent_form_v1` and `payment_ai_call_missed_v1` are **MARKETING** — the same fate `ai_caller_missed_v2` met. Consequences: they bill at the marketing rate, they answer to marketing consent (which is why the send reads the template's own category), and the wording must stay transactional or the next review can reject rather than reclassify. Read `whatsapp_templates.category` before quoting a price or a category anywhere.

## Related files

**Backend**
- `src/Payment/PaymentLinkAutomation.php`, `src/Payment/PaymentLinkAutomationSend.php` — the rule + the ledger
- `src/Payment/Repositories/PaymentLinkAutomationRepository.php` (+ `src/Payment/Facades/PaymentLinkAutomationRepository.php`) — create / update / setActive / delete / **claimSend** / requeue / recordResult
- `src/Payment/Support/PaymentAutomationVariables.php` — the fixed mapping; `src/Payment/Support/PaymentAutomationPresenter.php` — the Buyers cell
- `src/Payment/PaymentLink.php` (`aiCallProfile()`, `automations()`) + `src/Payment/Repositories/PaymentLinkRepository.php` (`ai_call_profile_id` in the whitelist)
- `app/Actions/Payment/DispatchPaymentAutomationAction.php` — execute (on paid) / callMissed / afterCall / manual
- `app/Actions/Payment/PlacePaymentAiCall.php` — refusalFor / run; `app/Actions/PlaceAiVoiceCall.php` gained the `$meta` parameter
- `app/Jobs/Automation/SendPaymentAutomationWhatsApp.php` — the send job
- `app/Services/Payment/PurchaseFulfiller.php` — the hook; `src/VoiceAgent/Services/AiCallAftermath.php` — the payment branch; `src/VoiceAgent/AiVoiceCall.php` (`isPaymentCall()`, `FOLLOWUPS` + `FOLLOWUP_SENDS`); `src/VoiceAgent/Support/AiCallPresenter.php`
- `app/Http/Controllers/Main/AiCallbackController.php` — `mintToken(…, ['purchase' => …])` + the payment re-dial
- `app/Http/Controllers/Manage/Payment/PaymentLinksController.php` (`show`, `updateAiProfile`, `linkRow`, `automationRow`, `automationStats`, `cloudChannelsWithTemplates`, `callableProfiles`), `PaymentLinkAutomationsController.php`, `PaymentLinkBuyersController.php`, `app/Http/Controllers/Concerns/BuildsPaymentLinkBuyers.php`
- `app/Http/Requests/Manage/Payment/PaymentLinkAutomations/{StoreRequest,UpdateRequest}.php`, `app/Http/Requests/Manage/Payment/PaymentLinks/AiProfileRequest.php`

**Frontend**
- `resources/js/Pages/Manage/Payment/Links/Show.vue`, `Partials/Tabs/BuyersTab.vue`, `Partials/Tabs/AutomationTab.vue`, `Partials/AutomationRuleFormModal.vue`; `Index.vue` (View action)

**Migrations**
- `database/migrations/2026_09_01_100001_create_payment_link_automation_tables.php`, `2026_09_01_100002_add_ai_call_profile_to_payment_links.php`

**Routes**
- `manage.payment.payment-links.show` (`GET {id}`, declared last), `.ai-profile` (`PUT {id}/ai-profile`), `.automations.{store,update,toggle,destroy}`, `.buyers.call` (throttled), `.buyers.send` — all `view-sales` read / `manage-sales` write (`routes/web.php`)

**Tests**
- `tests/Feature/Payment/PaymentAutomationTest.php` (on-paid exactly-once across replays, the launch cutoff, refunds, the job's variables + skips, the optional `{{5}}` link on a 5- vs 4-variable receipt, manual Send, rule validation, the Show page) · `tests/Feature/Payment/PaymentAiCallTest.php` (Call now + meta, the aftermath branch — once per call, never the funnel template, lead-silent, the daily cap, the per-buyer consent link minted / reused / signed-preferred, the webinar variables — and the call-me-again token round trip)

See also: [Payment Items](/docs/modules_handbook/manage/payments/payment-links/readMe.md) · [AI Voice Agent](/docs/modules_handbook/shared/voice-agent/readMe.md) · [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md) (the pattern this mirrors).
