# 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, spec in `docs/temp/payment-aicall-whatsapp-planning.md`.

## 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).
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 (the consent form link) — 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 after-call form link, a field so it changes without a deploy), `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}}` | Link button |
|---|---|---|---|---|---|
| When they pay | buyer name (`朋友` when blank) | item title (frozen on the purchase) | amount, e.g. `MYR 100.00` | `provider_payment_id` (`pi_…`), falling back to `payment_reference` | none |
| After the AI call | buyer name | the rule's **Link** field | | | none |
| AI call not answered | buyer name | item title | | | **exactly one**, its `{{1}}` suffix = `AiCallbackController::mintToken($lead, $profileSlug, ['purchase' => $id])` |

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 / opted out of UTILITY), 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`**. 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()`, which insists a `ROLE_USER` turn exists — Retell analyses pickup-and-hang-up calls too — and records `lead_silent` otherwise) **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.
- **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, the Link field (after-call), 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`; 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.

## 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, 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 — 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).
