# Planning — Meta Ads × Funnel Integration + Lead Source Consolidation

**Status:** ✅ **BUILT (Phases 1–3, 2026-07-24)** — decisions were answered and the work implemented the same day. Kept for the rationale; the module handbooks are the living docs ([marketing](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md), [landing-lead-capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md), [leads](/docs/modules_handbook/manage/leads/readMe.md)).

**Decisions taken (Part 3):** Q1 Messenger(7) **retired** → its writer stamps Other (user: hard to capture Messenger leads) · Q2 remap as specified (prod had no 1/2/3 data anyway) · Q3 XOR confirmed · Q4 blank-source leads **backfilled to Other** + all four writers now stamp Other via `attachDefaultSource` (user chose backfill over leave-blank) · Q5 ad-URL builder went **straight onto Funnel Show → Landing tab** (the concurrent funnels session was stopped, unblocking it) · Q6 one Funnel bucket.

**Deviations from the proposal:** FLG per-lead ids are read from `flg_leads.raw['meta']` (no new columns — the payload was already stored verbatim); `meta_ad_refs` has no nightly refresh (stale refs re-resolve on next sighting instead); the campaign sync now pulls **all** objectives (not a widened whitelist). Migrations `2026_07_24_0000{01,02,03}` written but **not run** on the main DB. 3 pre-existing test failures documented (2× PhoneNumber 00-prefix canonicalisation returning empty, 1× CheckIn scan 403-vs-302) — unrelated to this work.

---

*(original proposal below)*
**Goal:** run Meta (Facebook/Instagram) ads that drive visitors to a **funnel landing page**, where they register for a session and become a lead/user — and for every such lead, know exactly **which campaign / adset / ad** brought them in. At the same time, consolidate the lead `marketing_source` taxonomy down to: **Owner Listing, Meta Lead Gen, Other, Property Match, + Funnel (new)**.

> ⚠️ **Concurrent work notice:** another session is actively refactoring the Events/Funnels module (Occasional removal, slot-level members-only visibility + `2026_07_23_000001` migration, funnel-hub counts, D-label removal). Anything in this plan touching `FunnelsController`, `EventSeries`, `EventRepository`, `SeriesController`, the events section of `routes/web.php`, or the funnels readMe **must wait for that work to land**. The phases below are ordered so Phase 1–2 avoid those files entirely.

---

## Part 0 — Current state (verified against code, not docs)

### 0.1 The funnel structure (4 layers)

```
EventFunnel (event_funnels)           "Funnel" — marketing program w/ landing page /{slug}
  └─ EventSeries (event_series)      "Slot"   — session template (title/time/mode), no date
       └─ Event (events)             "Session"— slot materialised on a concrete date
            └─ EventRegistration     one lead joining one session (attendance tracking)
```

- Landing pages are **Vue components by slug convention**: `resources/js/Pages/Landings/{funnel-slug}/Index.vue` (funnel) and `.../{funnel-slug}/{slot-slug}/Index.vue` (slot), falling back to `Default.vue` / `DefaultSlot.vue`. The `event_funnels.landing_bundle` column **no longer exists** (dropped 2026-06-19).
- Public routes: `GET /{slug}` → `LandingController@index`, `GET /{funnel}/{slot}` → `@slot` ([routes/main.php:309-319](/routes/main.php)).

### 0.2 The registration flow (the path our ads will feed)

`LandingController` reads tracking from the **URL query string** into Inertia props → `LeadCaptureForm.vue` carries them as hidden fields → `POST /register` → `RegisterLeadAction`:

1. `resolveAccount()` — **email-only** lookup (deliberate security: this flow mails a magic sign-in link; an unverified typed phone must never resolve an account). Creates a Non-Member User (`Str::random(40)` password) if new.
2. `LeadRepository::firstOrCreateForUser` — one lead per person.
3. `LeadFunnelRepository::attach($lead, $funnelId, [...])` — writes the **`lead_funnels`** attribution row (`firstOrCreate` on `(lead_id, event_funnel_id)` → **first touch wins**, never overwritten).
4. Auto-enrol: slot landing → the one auto-picked session (`EventRegistration::SOURCE_LANDING`) + Zoom registrant sync; funnel landing → all upcoming sessions.
5. WhatsApp consent + funnel welcome message, magic sign-in email, `EnrichLeadJob`.

### 0.3 Attribution plumbing — what already works

`lead_funnels` **already has every column we need**: `marketing_source`, `utm_source/medium/campaign/content/term`, `fbclid`, **`ad_id`, `adset_id`, `campaign_id`**, `placement`, `referrer_url`, `landing_url`, `ip_address`, `user_agent` ([migration 2026_06_18_000002](/database/migrations/2026_06_18_000002_create_lead_funnels_table.php)).

The **funnel main landing** already captures `campaign_id` / `adset_id` / `ad_id` / `placement` from the URL ([LandingController.php:284-287](/app/Http/Controllers/Main/LandingController.php#L284-L287)) and `RegisterLeadAction` persists them ([RegisterLeadAction.php:88-91](/app/Actions/RegisterLeadAction.php#L88-L91)). **The pipe is built end-to-end** — it's just never fed, because no ad URL carries those params today.

### 0.4 The gaps (each becomes a work item)

| # | Gap | Evidence |
|---|---|---|
| G1 | **Slot landing drops the 4 ad params** — `@slot`'s tracking block passes only `utm_*` + `fbclid` | [LandingController.php:144-155](/app/Http/Controllers/Main/LandingController.php#L144-L155) vs `:284-287` |
| G2 | **No ad URL is ever generated with the params.** Nothing in the codebase produces `?campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}`; `MetaAdLauncherService` never sets `url_tags`. `fbclid` cannot be decoded into an ad id | grep `{{campaign` / `url_tags` = zero hits |
| G3 | **No adset/ad NAME storage anywhere** — `meta_campaigns` stores campaign-level `name` only; zero Graph calls touch `/adsets`; raw ids render naked in AttributionTab / Leads index / export | `meta_campaigns` migration; [AttributionTab.vue:33-36](/resources/js/Pages/Manage/Leads/Partials/Tabs/AttributionTab.vue#L33-L36) |
| G4 | **No campaign → funnel mapping.** `meta_campaigns.project_id` ties a campaign to a *project* (FLG instant-form routing) only; grep `funnel` in `src/Marketing/` = zero | [meta_campaigns migration:32](/database/migrations/2026_07_22_110001_create_meta_campaigns_table.php#L32) |
| G5 | **`resolveSource()` sniffs `utm_source` for fmx/mkt strings** → FMX(1)/MKT(2)/Organic(3)/Other(4). No "Funnel" concept | [RegisterLeadAction.php:261-278](/app/Actions/RegisterLeadAction.php#L261-L278) |
| G6 | **Campaign sync only pulls lead-gen objectives** (`OUTCOME_LEADS` / `LEAD_GENERATION`) — a traffic/awareness campaign pointing at our landing page would never appear in `meta_campaigns` | [FacebookAdsService.php:153-180](/src/Marketing/Services/FacebookAdsService.php#L153-L180) |
| G7 | **Tracking params live only in client-side props** — navigating from funnel page to slot page loses them (no session/cookie persistence) | landing-capture flow analysis |
| G8 | *(bonus, FLG side)* Meta's leadgen webhook payload carries **per-lead `ad_id` + `adgroup_id`** but they're buried unused in `flg_leads.raw['meta']`; synced+tied campaigns stamp `adset_id`/`ad_id` = NULL | [SyncFlgLeadToCrmAction.php:92-97](/app/Actions/SyncFlgLeadToCrmAction.php#L92-L97) |
| G9 | *(scaffold bug)* `MakeSlotLanding` stub expects a `sessions` array prop but `SlotLanding.vue` passes a single `session` — scaffolded slot designs always show "No upcoming sessions" and clobber `tracking.event` | `MakeSlotLanding.php` stub vs [SlotLanding.vue:30](/resources/js/Pages/SlotLanding.vue#L30) |

### 0.5 Current `marketing_source` taxonomy & writers

| Value | Label | Writer today |
|---|---|---|
| 1 FMX | FMX | `resolveSource()` when `utm_source` contains fmx/funnelmastery; admin modals; seeder |
| 2 MKT | MKT | `resolveSource()` mkt/marketthink; admin modals; seeder |
| 3 Organic | Organic | `resolveSource()` empty utm_source; admin modals; seeder |
| 4 Other | Other | `resolveSource()` fallback; 5 import/Zoom linkers; admin modal default |
| 5 Owner Listing | Owner Listing | `ProcessOwnerListingJob` (⚠️ bypasses `attach()`, keys on `(lead_id, marketing_source)`) |
| 6 Meta Lead Gen | Meta Lead Gen | `SyncFlgLeadToCrmAction` (FLG instant-form webhook) |
| 7 Messenger | Messenger | `ProcessInboundMessengerWebhook` (per-lead m.me ref / CTM) |
| 8 Property Match | Property Match | `PropertyMatchRepository` (created-outcome only) |

Also relevant: **some lead-creating paths write no `lead_funnels` row at all** (walk-in check-in, Stripe, purchase-history import, WhatsApp ContactLinker, phone-call matcher) — they show a blank source and are invisible to source filters. A separate `LeadLinker::SOURCE_*` **string** taxonomy names entry points (never persisted) — don't confuse the two.

---

## Part 1 — Proposed target design

### 1.1 New source taxonomy (5 buckets + 1 decision)

| Value | Label | Meaning after consolidation |
|---|---|---|
| 4 | Other | Everything that isn't one of the named channels (imports, Zoom linkers, admin manual default, non-Meta ads) |
| 5 | Owner Listing | unchanged |
| 6 | Meta Lead Gen | unchanged (instant-form leads via FLG webhook) |
| 8 | Property Match | unchanged |
| **9** | **Funnel** *(NEW)* | Any registration through a funnel/slot landing page (`RegisterLeadAction`), regardless of which agency's ad drove it |
| 7 | Messenger | **DECISION NEEDED** — user's list doesn't mention it. Recommend **keep** (it's a distinct, working channel with its own webhook writer); folding it into Meta Lead Gen would lie about the mechanics |

- **Do not renumber** kept values (4/5/6/8 keep their ints) — renumbering would corrupt history and break `ProcessOwnerListingJob`'s `(lead_id, marketing_source)` key.
- **Retire 1 (FMX) / 2 (MKT) / 3 (Organic)** from `LeadFunnel::SOURCES` (the UI/validation whitelist auto-follows the constant). "Which agency" is NOT lost: the raw `utm_source` string stays stored on every row, and FMX/MKT as *ad-account* brands on the Marketing → Ads page are a completely separate config (`FB_AD_ACCOUNT_FMX/MKT`) that this change must not touch.
- `resolveSource()` in `RegisterLeadAction` collapses to: **funnel registration ⇒ `SOURCE_FUNNEL`, always.** (The row's `event_funnel_id`, `utm_*` and ad ids carry the detail.)

**Data migration** (one new migration, values only — no schema change):

```
UPDATE lead_funnels SET marketing_source = 9  WHERE marketing_source IN (1,2,3) AND event_funnel_id IS NOT NULL;
UPDATE lead_funnels SET marketing_source = 4  WHERE marketing_source IN (1,2,3) AND event_funnel_id IS NULL;
```

(1/2/3 with a funnel = old landing registrations → Funnel; without a funnel = admin free-choice picks → Other. Zoom linkers' rows are already 4-with-funnel and stay untouched — under this design "source=Funnel" strictly means "came through a landing page", which matches the user's intent.)

**Ripple list for the taxonomy change** (all verified referencing sites):
- `src/Lead/LeadFunnel.php` — constants + `SOURCES` (add 9, remove 1/2/3).
- `app/Actions/RegisterLeadAction.php::resolveSource()` — return `SOURCE_FUNNEL`.
- `database/seeds/LeadsSeeder.php` — re-seed with new values.
- Form Requests already use `Rule::in(array_keys(LeadFunnel::SOURCES))` → auto-tighten (Leads StoreRequest, SalesProjects StoreLeadRequest, Zoom Polls CreateLeadRequest).
- Frontend hardcoded defaults stay valid (`source: 4`): `LeadFormModal.vue:18`, `CreateLeadModal.vue:28`, `AddLeadModal.vue` (`defaultSource` prop), `Events/Show.vue:27` fallback (`source: 3` → change to 4; the prop is currently unconsumed anyway).
- Badge color maps in `AttributionTab.vue` / `LeadDetailModal.vue` etc. — add a color for Funnel (and fix the already-missing `gold` for Property Match while there).
- Tests pinned to old values: `LeadLinkerTest` (MKT case), `LeadStoreIdentityTest`, `PurgeAllLeadsTest` (raw `1`), several Zoom/Events tests using ORGANIC — update alongside.
- Handbook: `docs/modules_handbook/manage/leads/readMe.md` source list (currently already missing Property Match) + migration comment drift noted in `2026_06_18_000002` (historical file — leave, but the handbook must be right).

### 1.2 Ad → landing URL contract (how the IDs arrive)

Meta replaces **URL dynamic parameters** at click time. The standard template every funnel ad must use as its Website URL:

```
https://{APP_URL}/{funnel-slug}
  ?utm_source={agency}&utm_medium=paid_social&utm_campaign={{campaign.name}}
  &campaign_id={{campaign.id}}&adset_id={{adset.id}}&ad_id={{ad.id}}&placement={{placement}}
```

- Param names intentionally match what `LandingController` + `RegisterRequest` + `lead_funnels` already accept — **zero backend rename needed**.
- Works whether the ad is created in Ads Manager manually or launched from our portal.
- **New UI helper:** a "Copy ad URL" button that renders this template for a funnel (and per slot). Recommended home: the **Campaign Mapping / Marketing area** (NOT the Funnel Show page, to avoid the concurrent session's files) — e.g. a small "Ad URL builder" card on the Campaign Mapping page with a funnel dropdown. Phase 3 can move/duplicate it onto Funnel Show once that module settles.

### 1.3 Fix the capture gaps (landing side)

1. **G1 — slot landing:** add the 4 missing lines (`campaign_id`, `adset_id`, `ad_id`, `placement`) to `@slot`'s tracking block ([LandingController.php:144-155](/app/Http/Controllers/Main/LandingController.php#L144-L155)). 4-line fix, zero risk. *(File is NOT in the other session's diff — safe.)*
2. **G7 — param persistence across navigation (recommended, small):** funnel page → slot page navigation currently drops the query string. Two options:
   - **(a) Link pass-through:** landing designs' internal links append the current query string (frontend util, e.g. in `Landing.vue`/`SlotLanding.vue` provide a `withTracking(url)` helper to designs). Cheap, explicit.
   - **(b) First-touch session cache:** middleware stores the first-seen ad params in the session (TTL ~1h) and `LandingController` falls back to it when the URL is bare. More robust (survives any wandering), slightly more machinery.
   - **Recommendation:** (a) now; consider (b) only if real traffic shows param loss.
3. **G9 — scaffold bug:** fix `MakeSlotLanding` stub to use the single `session` prop (or fix `SlotLanding.vue` to pass `sessions` — pick the stub, it's the outlier). Prevents every future slot design starting broken.

### 1.4 Name resolution — new `meta_ad_refs` lookup table (G3)

Raw ids are useless to admins. Store a name per id, at all three levels, resolved lazily:

**New table `meta_ad_refs`** (follows project conventions — no FKs, blame cols):

| Column | Type | Notes |
|---|---|---|
| `id` | bigIncrements | PK |
| `level` | unsignedTinyInteger | `1=campaign, 2=adset, 3=ad` (constants on the model) |
| `meta_id` | string(60) | the Meta object id — **unique together with `level`** |
| `name` | string(191) nullable | resolved name |
| `parent_campaign_id` | string(60) nullable | for adset/ad rows |
| `parent_adset_id` | string(60) nullable | for ad rows |
| `account_id` | string(60) nullable | owning ad account |
| `status` | string(40) nullable | effective_status snapshot |
| `resolved_at` | datetime nullable | last successful Graph fetch |
| `failed_at` / `fail_count` | datetime nullable / unsignedTinyInteger | back-off for dead ids (deleted ads, token loss) |
| blame + timestamps | | |

**Fill strategy (lazy + batch):**
- On registration with an `ad_id` (queued, after-commit — never in the request path): dispatch `ResolveMetaAdRefsJob` → one Graph call `GET /{ad_id}?fields=name,adset{id,name},campaign{id,name}` resolves **all three levels at once** → upsert 3 rows. Token: reuse the exact fallback chain `MetaCampaignSyncService::materialiseForCampaign` already uses (integration token → `FB_AD_ACCESS_TOKEN` → `META_ACCESS_TOKEN`), via a new `FacebookAdsService::adHierarchyWithToken()` method.
- Nightly `meta:sync-campaigns` additionally refreshes names for refs seen in the last N days (cheap batch, `ids=` batching).
- **Display:** `LeadsController::transform()` / AttributionTab show `name (id)` when a ref exists, falling back to the raw id. One indexed lookup per shown registration (eager, keyed by the 3 id columns).

*(Considered and rejected: full adset/ad catalogue sync of every account — wasteful; we only ever need names for ids that actually appear on leads. The lazy model is O(leads), not O(ad objects).)*

### 1.5 Campaign → Funnel tie (G4, mirrors the existing project tie)

Add **`event_funnel_id` (nullable, indexed)** to `meta_campaigns` (new migration — the table shipped 2 days ago, but per project rules committed migrations are never edited; ship an `add_event_funnel_id_to_meta_campaigns` migration).

- **Campaign Mapping page**: the Assign modal offers a destination: *Project* (existing, materialises `flg_campaigns` routing) **or** *Funnel* (new). Mutually exclusive — a campaign's leads either arrive by instant form (project routing) or by landing page (funnel). Guard in `AssignRequest` + repository (`setFunnel()` clears `project_id` and vice-versa).
- The funnel tie does **not** need any `flg_campaigns` materialisation (landing leads self-report via URL params). Its value:
  1. **Reporting join:** funnel-level ROI = spend (Graph insights by campaign) × leads/registrations (`lead_funnels.campaign_id ∈ funnel's tied campaigns` ∪ `event_funnel_id = funnel`).
  2. **Fallback classification:** a lead arriving with `campaign_id` but somehow degraded UTM data can still be associated.
  3. **Visibility:** the Mapping DataTable shows where every active campaign points (project / funnel / not tied).
- **G6 fix rides along:** widen `activeLeadCampaignsWithToken` → a generalised `activeCampaignsWithToken` that also accepts traffic-family objectives (`OUTCOME_TRAFFIC`, `LINK_CLICKS`, `OUTCOME_AWARENESS`, `OUTCOME_ENGAGEMENT`), so landing-page campaigns get synced and are tie-able. Keep a UI hint of each campaign's objective (already stored) so admins can tell instant-form vs traffic campaigns apart.

### 1.6 FLG side bonus fix (G8 — small, high value, independent)

In `WebhookController@receive`, extract Meta's per-lead `ad_id` + `adgroup_id` from the webhook `$value` before it's buried in `raw`; pass them through `SyncFlgLeadToCrmAction` so `lead_funnels.ad_id/adset_id` get the **true per-lead ad** instead of (portal-launch snapshot | NULL). New nullable columns `meta_ad_id`, `meta_adset_id` on `flg_leads` for auditability. Instantly makes synced+tied campaign leads fully attributed, and `meta_ad_refs` (§1.4) then gives them names too.

### 1.7 What this plan deliberately does NOT do

- **No `RegisterLeadAction` → `LeadLinker` migration.** The email-only account resolution is a documented security decision ("email-only by design" — the magic-link flow must never resolve accounts by unverified phone). Migrating it is a separate identity-pipeline work item, orthogonal to attribution. This plan only touches `resolveSource()` inside it.
- **No portal ad-launching for traffic ads (yet).** `MetaAdLauncherService` is hardcoded to `OUTCOME_LEADS` + instant-form creative. A traffic-objective launch path (creative `link` = funnel URL + `url_tags` with macros) is listed as Phase 4 — the infrastructure (Graph helpers, rollback, 1-campaign-1-adset-1-ad shape) is all reusable, but manual Ads Manager + the URL template covers the need first.
- **No decision on the blank-source populations** (Stripe / walk-in / WhatsApp / phone-call leads with no `lead_funnels` row). Backfilling them into "Other" would create thousands of attribution-empty rows for little gain. Leave blank = "no funnel registration", which is true. Flagged for the user (Q4 below).

---

## Part 2 — Phased execution plan

### Phase 1 — Capture correctness + source consolidation *(no dependency on the other session; smallest useful release)*

| # | Task | Files (indicative) |
|---|---|---|
| 1.1 | Slot landing: add the 4 ad params to the tracking block (G1) | `app/Http/Controllers/Main/LandingController.php` |
| 1.2 | `SOURCE_FUNNEL = 9` + trim `SOURCES` to the 5(+1) buckets | `src/Lead/LeadFunnel.php` |
| 1.3 | `resolveSource()` → always `SOURCE_FUNNEL` | `app/Actions/RegisterLeadAction.php` |
| 1.4 | Data migration remapping 1/2/3 per §1.1 | new migration |
| 1.5 | UI ripple: badge colors (add Funnel, fix gold), hardcoded default `source: 3` fallback → 4 | `AttributionTab.vue`, `LeadDetailModal.vue`, `Events/Show.vue` |
| 1.6 | Seeder + pinned tests update | `LeadsSeeder.php`, ~6 test files |
| 1.7 | Fix `MakeSlotLanding` stub session prop (G9) | `app/Console/Commands/MakeSlotLanding.php` |
| 1.8 | Tracking pass-through helper for internal landing links (G7 option a) | `Landing.vue` / `SlotLanding.vue` + a small util |
| 1.9 | Handbook sync: leads readMe source list; landing-lead-capture readMe (also fix its stale "password = email" + slot-sessions drift) | docs |

**Definition of done:** a manually-created Ads Manager ad using the URL template produces a lead whose Attribution tab shows source **Funnel** + utm set + the 3 raw Meta ids, from BOTH funnel and slot landings.

### Phase 2 — Names + campaign↔funnel mapping *(Marketing module only — still no conflict)*

| # | Task | Files |
|---|---|---|
| 2.1 | `meta_ad_refs` table + model + repository (§1.4) | new migration, `src/Marketing/` |
| 2.2 | `FacebookAdsService::adHierarchyWithToken()` (+ batched refresh variant) | `src/Marketing/Services/FacebookAdsService.php` |
| 2.3 | `ResolveMetaAdRefsJob` dispatched after funnel registration w/ `ad_id` (after-commit, fail-soft) + nightly refresh hook in `meta:sync-campaigns` | `app/Jobs/Marketing/`, `MetaCampaignSyncService` |
| 2.4 | Leads UI: show resolved names in AttributionTab / index expand / export | `LeadsController::transform`, Vue partials, `LeadsExport` |
| 2.5 | `event_funnel_id` on `meta_campaigns` + `setFunnel()` repo method (mutual exclusion w/ project) | new migration, `MetaCampaignRepository` |
| 2.6 | Campaign Mapping UI: destination selector (Project / Funnel), funnel column + filter; widen sync objectives (G6) | `CampaignMappingController`, `CampaignMapping.vue`, `AssignRequest`, `FacebookAdsService` |
| 2.7 | "Ad URL builder" card on Campaign Mapping (funnel dropdown → copy template URL) | `CampaignMapping.vue` |
| 2.8 | Handbook sync: `docs/modules_handbook/manage/meta-ads/marketing/readMe.md` | docs |

### Phase 3 — FLG per-lead attribution + reporting *(after the other session lands for 3.2)*

| # | Task | Notes |
|---|---|---|
| 3.1 | G8: extract per-lead `ad_id`/`adgroup_id` from the leadgen webhook → `flg_leads` cols → `SyncFlgLeadToCrmAction` prefers per-lead values | independent of the funnels refactor; also feeds `meta_ad_refs` |
| 3.2 | Funnel "Ads" tab: per-campaign spend × leads × registrations × attendance for the funnel's tied campaigns | **touches `FunnelsController` + Funnel `Show.vue` — WAIT for the concurrent session** |
| 3.3 | Move/duplicate the Ad URL builder onto Funnel Show | same constraint |

### Phase 4 — Optional / later

- Portal launcher for traffic ads (`MetaAdLauncherService` `OUTCOME_TRAFFIC` path: creative link = funnel URL, set `url_tags` with the macro template so even portal-launched ads self-attribute).
- Session-level first-touch cache (G7 option b) if pass-through proves insufficient.
- Decide the blank-source populations' fate (Q4).
- `RegisterLeadAction` → LeadLinker migration (separate identity work item; keep email-only semantics).

---

## Part 3 — Decisions needed from the user

| # | Question | Recommendation |
|---|---|---|
| Q1 | **Messenger (7):** keep as its own source, or fold away? Your list omitted it | **Keep** — distinct working channel; folding misrepresents mechanics |
| Q2 | **Historical FMX/MKT/Organic rows:** OK with the remap rule (funnel-attached → Funnel, un-funneled → Other)? Raw `utm_source` strings remain for agency-level reporting | Yes as specified |
| Q3 | **Campaign tie exclusivity:** confirm a Meta campaign ties to Project **XOR** Funnel (not both) | XOR — the two lead paths are mechanically different (instant form vs landing) |
| Q4 | **Blank-source leads** (Stripe, walk-in, WhatsApp, phone-call — no `lead_funnels` row at all): leave blank, or backfill an "Other" row? | Leave blank (blank = "no funnel registration", which is true); revisit if source reporting needs 100% coverage |
| Q5 | **Ad URL builder placement** in Phase 2 on Campaign Mapping (to dodge the funnels refactor), moving to Funnel Show in Phase 3 — OK? | Yes |
| Q6 | **`SOURCE_FUNNEL` granularity:** one bucket for all landing registrations (ad-driven and organic alike; `utm_*`/ad ids differentiate), or split "Funnel (paid)" vs "Funnel (organic)"? | One bucket — the row's own utm/ad columns already answer "paid or not"; two buckets would re-create the FMX/MKT sniffing fragility |

---

## Part 4 — Risks & guardrails

- **First-touch semantics:** `attach()` never overwrites — a returning lead who re-registers via a NEW ad keeps the OLD attribution. That is the documented, intended behaviour; the remap migration is therefore the *only* way historical rows change. Don't "improve" this in passing.
- **Owner Listing writer bypass:** `ProcessOwnerListingJob` keys `firstOrCreate` on `(lead_id, marketing_source=5)` — untouched by this plan (5 keeps its int). Any future renumbering must revisit it.
- **NULL-funnel uniqueness quirk:** `unique(lead_id, event_funnel_id)` doesn't bind NULLs — multiple un-funneled rows per lead are legal and already occur (OTHER + OWNER_LISTING). Reports must `GROUP BY` accordingly.
- **Graph API budget:** `ResolveMetaAdRefsJob` is 1 call per *new* ad id (not per lead — dedupe on the unique key), with fail back-off. Nightly refresh batches via `ids=`. Negligible vs the existing insights traffic.
- **Token health:** name resolution silently degrades to raw ids when no token is available (same fail-soft philosophy as `FacebookAdsService`). Never block registration on attribution work — everything post-`attach()` is queued/after-commit.
- **Concurrent session:** Phases 1–2 touch none of its files (`LandingController`, `RegisterLeadAction`, `LeadFunnel`, Marketing module, seeds/tests). Phase 3.2/3.3 explicitly gated on it landing. Re-verify `git status` before starting implementation.
- **Multi-DB testing:** per project convention, run suites against `petav3_testing_claude` (`DB_DATABASE=… REFERENCE_DB_DATABASE=… vendor/bin/phpunit`); never `migrate:fresh` the main `petav3` DB.

---

## Appendix — key files quick reference

| Concern | File |
|---|---|
| Landing tracking capture | `app/Http/Controllers/Main/LandingController.php` (`renderLanding` :277-290, `slot` :144-155, `register` :302-358) |
| Registration + source resolution | `app/Actions/RegisterLeadAction.php` (attach :80-96, resolveSource :261-278) |
| Attribution ledger | `src/Lead/LeadFunnel.php`, `src/Lead/Repositories/LeadFunnelRepository.php::attach` |
| Attribution UI | `resources/js/Pages/Manage/Leads/Partials/Tabs/AttributionTab.vue`, `Leads/Index.vue`, `app/Exports/LeadsExport.php` |
| Meta campaign catalogue + sync | `src/Marketing/MetaCampaign.php`, `app/Services/Marketing/MetaCampaignSyncService.php`, `app/Console/Commands/Marketing/SyncMetaCampaigns.php` (daily 05:00 MYT) |
| Graph client | `src/Marketing/Services/FacebookAdsService.php` (token transports `get`/`getWithToken`) |
| Campaign Mapping | `app/Http/Controllers/Manage/Marketing/CampaignMappingController.php`, `resources/js/Pages/Manage/Marketing/CampaignMapping.vue` |
| FLG webhook → CRM | `app/Http/Controllers/Manage/FacebookLeadGenerator/WebhookController.php`, `app/Actions/SyncFlgLeadToCrmAction.php` |
| Ad launcher (Phase 4 base) | `app/Services/FacebookLeadGenerator/MetaAdLauncherService.php` |
| Identity pipeline (context) | `src/Lead/Services/LeadLinker.php`, `docs/modules_handbook/shared/lead-linking/readMe.md` |
| Module handbooks | `docs/modules_handbook/manage/meta-ads/{connection,marketing}/readMe.md`, `manage/leads/readMe.md`, `main/landing-lead-capture/readMe.md`, `manage/events/funnels/readMe.md` |
