# Facebook Conversions API (CAPI) Integration

## Context

The CRM currently has no way to report lead/sale outcomes back to Meta. Leads can already originate
from Facebook (`LeadController::webhook_facebook_lead_capture`, source `'Facebook'`), but that's a
one-way ingestion path — nothing tells Meta's ad algorithm which of those leads turned into real
revenue. Facebook CAPI closes that loop: the CRM sends server-side conversion events (Lead created,
qualified, converted, sale value) straight to Meta's Graph API. This makes ad optimization resilient to
iOS ATT/ad-blockers (server-side, not a browser pixel) and lets Meta optimize spend toward actual
converted revenue instead of raw form-fills.

Per your answers: this should cover the **full funnel** (lead creation, status changes, and sale
conversion). Credentials (Pixel ID + access token) are scoped **per CRM instance** — one set for the
whole system — but must be **user-provided through an admin-editable settings endpoint and stored in
the database**, not hardcoded in `.env`/`config`. This matches `WabaCredential`/`SmsCredential`
exactly: a single-row credential table, editable via a `/fetch` + `/update` controller pair, not env vars.

## Design (matches existing conventions)

Reused patterns, confirmed from exploration:
- Credential CRUD pattern: `app/Models/WabaCredential.php` + `app/Http/Controllers/WabaCredentialController.php`
  — single-row table (`WabaCredential::first()`, update-or-create), `fetch_waba_credential` /
  `update_waba_credential` methods, permission-gated (`setting.waba_credential.read`/`.override`),
  `activity()`-logged. `FacebookCapiCredential` follows this exact shape.
- Config split convention: secrets (api_key, tokens) live in the DB credential row; non-secret config
  (Graph API base URL, API version) goes in `config/services.php` reading from `.env`, same as
  `config('services.waba')`. So the Pixel ID + access token are DB-stored/admin-entered, while just the
  Graph API version/base URL are ordinary `.env` config (not secrets, safe to have a sane default).
- Outbound HTTP pattern: `app/Services/CampaignExecutionService.php` sends via `Http::` facade.
- Queued dispatch pattern: `app/Jobs/ExecuteCampaignJob.php` — CAPI calls should be queued the same way
  so a slow/failed Graph API call never blocks `LeadController`'s response.
- No event/observer system exists anywhere in this codebase (confirmed — no `observe(`, `Event::`, or
  `::dispatch(` in Lead/LeadController). Staying consistent with current style means firing CAPI calls
  via small inline dispatch calls at the existing transition points, not introducing a new Events layer.

### 1. Database
- New migration `create_facebook_capi_credentials_table`: `id, pixel_id, access_token, test_event_code
  (nullable), is_active (bool, default true), created_by (FK users), timestamps`. Single-row table,
  same as `waba_credentials` — no `company_id`.
- New migration `create_facebook_capi_logs_table` (lightweight audit trail, mirroring the visibility
  `CampaignLog` gives campaigns): `id, lead_id, event_name, status (sent/failed), http_status,
  response_body (text, nullable), created_at`. Needed because Graph API failures (bad token, expired
  pixel) are silent otherwise — there's no existing log channel for this.

### 2. Models
- `app/Models/FacebookCapiCredential.php` — plain model, `fillable: pixel_id, access_token,
  test_event_code, is_active, created_by`, no relations (mirrors `WabaCredential`).
- `app/Models/FacebookCapiLog.php` — simple log model, `belongsTo(Lead::class)`.

### 3. Service — `app/Services/FacebookCapiService.php`
- `sendEvent(Lead $lead, string $eventName, array $customData = []): void`
  - Resolves credential via `FacebookCapiCredential::first()`; no-op (log skip) if missing or `is_active`
    is false.
  - Builds Graph API payload (`POST https://graph.facebook.com/{version}/{pixel_id}/events`):
    `event_name`, `event_time`, `action_source: 'system_generated'`, `event_id` (for future pixel
    dedup — `lead_id . ':' . eventName . ':' . time()`), `user_data` (SHA-256 hashed `em`/`ph` from
    Lead's email/phone, normalized lowercase/trimmed per Meta's spec), `custom_data`, and
    `test_event_code` when set on the credential (lets you verify in Meta's Test Events tool before going
    live).
  - Add non-secret config (Graph API base URL/version only) to `config/services.php` under a
    `facebook_capi` key, matching `config('services.waba')`.
- `app/Jobs/SendFacebookCapiEventJob.php` — thin queued wrapper calling the service; dispatched instead of
  calling the service directly, so failures/latency never affect the Lead request/response cycle. Catches
  and logs exceptions into `facebook_capi_logs` rather than retrying indefinitely (bad tokens will fail
  forever — no point retrying those).

### 4. Trigger points (all inline dispatch calls, matching how `activity()->log()` calls are already
   sprinkled through `LeadController`)
Map standard/custom Meta event names to existing Lead lifecycle points in `LeadController.php`:
| Lead lifecycle point | Meta event |
|---|---|
| Lead creation (`store()`, and non-webhook creation flows) | `Lead` |
| `convert_to_verified()` | Custom event `LeadQualified` |
| `convert_to_call_back()` | Custom event `LeadScheduled` |
| `convert_to_not_interest()` | Custom event `LeadDisqualified` (useful for building Meta exclusion audiences) |
| `LeadSale::create(...)` (conversion to sale, ~line 1758) | `Purchase`, with `custom_data.value` = `LeadSale.total`, `currency` (need to confirm currency source/default) |

Each call: `dispatch(new SendFacebookCapiEventJob($lead, 'Lead'))` (or the relevant event name)
immediately after the existing `activity(...)->log(...)` call at that point.

### 5. Credential management endpoint
- `app/Http/Controllers/FacebookCapiCredentialController.php`, same shape as `WabaCredentialController`:
  `fetch_facebook_capi_credential`, `update_facebook_capi_credential` (`pixel_id`, `access_token`,
  `test_event_code` validation), permission-gated (`setting.facebook_capi_credential.read` /
  `.override`, new permissions to add wherever `waba_credential` permissions are currently seeded),
  `activity('facebook_capi_credential')` logging.
- Routes in `routes/app.php`, nested the same way as the other `setting` credential routes (inside the
  `setting` prefix, inside `account_validation` + `auth:sanctum` middleware):
  `setting/facebook_capi_credential/fetch`, `setting/facebook_capi_credential/update`.

### 6. Docs
Per CLAUDE.md's doc-maintenance table, this touches:
- `docs/api.md` — new credential endpoints.
- `docs/database.md` — two new tables.
- `docs/business-rules.md` — new lead-lifecycle → CAPI event mapping.

## Verification
1. Run migrations, set the `FacebookCapiCredential` row via the new `/update` endpoint using Meta's real
   Test Event Code.
2. Create a Lead via the API, confirm a `SendFacebookCapiEventJob` is queued and a `facebook_capi_logs`
   row is written with `status = sent`.
3. In Meta Events Manager → Test Events, confirm the `Lead` event appears in real time.
4. Walk a lead through `convert_to_verified` → `convert_to_call_back` → sale creation, confirm each
   corresponding event appears in Test Events with correct `custom_data.value`/currency on `Purchase`.
5. Test the skip path: deactivate the credential (`is_active = false`) and confirm no job is dispatched /
   a "skipped, no active credential" log line appears, with no error surfaced to the caller.
6. Test a bad/expired access token: confirm the job logs a `failed` row with the Graph API error body
   instead of throwing/retrying forever.
