# Marketing — Ad Insights, Traffics & Campaign Mapping (Manage · `Src\Marketing`)

**Context:** Manage portal · **Routes:** `manage.marketing.*` (`/manage/marketing/{ads,campaigns,campaign-mapping}`; `/manage/marketing/roas` is now a redirect) · **Permissions:** `view-marketing` opens the read surface, **split two ways since 2026-08-12**: Campaign Performance (`campaigns`, the `roas` redirect) needs `view-marketing` alone, while **Ad Insights (`ads`) and Campaign Mapping additionally require a Meta-account level** (`Permission::viewMetaAny()`, same rule as `/manage/facebook`) — they are the Meta integration's configuration side, so the reports-only **Marketing** role (`view-marketing`, no Meta level) reads Traffics but never these; `manage-marketing` still gates the Campaign-Mapping **sync / assign / backfill-leads** writes. The `FunnelMarketingTabs` **Meta Ads** tab mirrors the same rule (`canAny(META_ANY)`). · **Nav:** the pages sit under **two different** sidebar entries, on purpose.
**Traffics** (its own *Sales & Marketing* entry) fronts **ONE page since 2026-08-03**: the merged
**Traffics** page (`/manage/marketing/campaigns`) — delivery (spend, impressions, CTR, CPC, leads, CPL)
**and** return (buyers, revenue, ROAS, profit) in one table, switched by a client-side
**Delivery | Return** column toggle (`?view=`), at campaign / adset / ad grain. It used to be two
pages (Campaign Performance + Ad Return) behind a `traffics` SectionTabs strip; they were two views of
the SAME `AdPerformanceService::rollup()` rows, so the strip collapsed with the merge (a one-tab strip
has nothing to switch between) and `/manage/marketing/roas` now
**redirects** to `campaigns?view=return` with the caller's grain/range preserved. The `traffics`
SECTIONS entry was **revived 2026-08-04** with a genuinely different sibling: **Campaign
Performance** (this page) + **Payment** — a jump to `/manage/purchase-histories` (`view-sales`),
the ledger the ad revenue is measured against. That page belongs to the **Sales hub** (it renders
`SalesTabs` and lights the Sales nav entry; its path stays OUT of the Traffics entry's `prefixes`,
§15), so the Payment tab is a one-way door out of Traffics by design. Both readings answer
"which spend is working", which is a question about where leads come from rather than about building a
funnel; the section was buried two levels inside Funnels → Meta Ads until 2026-07-27.
**Campaign Mapping** (`/manage/marketing/campaign-mapping`) and **Ad Insights**
(`/manage/marketing/ads`) stay in **Funnels → Meta Ads** (Mapping · Setting → Account / Ads), which is
now that tab's landing page. Because the two entries share the `/manage/marketing` prefix, neither may
use it wholesale: Funnels lists the two specific sub-paths it still owns, or it would stay lit while
the reader is standing in Traffics (GUIDELINES §15).

Meta advertising **reporting + lead-routing glue**. Three pages: two read-only dashboards (**Ad Insights**, the merged **Traffics**) and one operational tool (**Campaign Mapping**) that ties externally-created Meta lead-gen campaigns to a **Project** so their leads auto-route into the CRM. This module reads Meta data; it does **not** own OAuth (see [Facebook Connection](/docs/modules_handbook/manage/meta-ads/connection/readMe.md)) or the lead webhook (that's FLG).

> **Supersedes the old `manage/ad-insights` doc.** That doc described only the read-only Ad-Insights page ("no database, no scheduling"). Since then the module gained a persisted `meta_campaigns` table, a daily `meta:sync-campaigns` command, the Campaigns overview page, and the Campaign-Mapping workflow — all documented here.

## The three pages at a glance

| Page | Route | Token used | Data source | Writes? |
|---|---|---|---|---|
| **Ad Insights** | `manage.marketing.ads` | **System env token** (`FB_AD_ACCESS_TOKEN`) | Live Graph API (2 fixed accounts: FMX, MKT) | no |
| **Traffics** (merged) | `manage.marketing.campaigns` (`manage.marketing.roas` redirects here) | none on the request | **Local** `meta_ad_insights` + `meta_ad_settings` + `lead_funnels` + `purchase_histories` | no |
| **Campaign Mapping** | `manage.marketing.campaign-mapping.index` | Per-admin OAuth tokens (on sync / assign / recover) | Local `meta_campaigns` table | **yes** (sync + assign + backfill-leads) |

**The single most important thing to understand:** there are **two separate token systems** inside `FacebookAdsService` — the Ads page uses one shared env token; the scheduled syncs + Mapping iterate the OAuth tokens stored by the connection module. Since 2026-07-29 **Traffics makes no Graph call at all** on the request path: it reads what the scheduler stored, so it stays fast and keeps working through a token expiry or a Meta outage.

## What it does
- **Ad Insights (`/manage/marketing/ads`).** A live spend/performance scoreboard for two hardcoded in-house marketing companies — **FMX** and **MKT**. Per account: 6 KPI tiles (Spend, Impressions, Clicks, CTR, CPC, Reach — in the account's own currency) + a top-25 campaign table, with a Last 7 / 30 / 90-day selector. Hits Graph **live on every load** (no cache). If `.env` isn't configured, shows a setup notice.
- **Traffics (`/manage/marketing/campaigns`, MERGED 2026-08-03).** Every campaign / adset / **ad** across **all** connected ad accounts, delivery AND return in one table. 6 KPI tiles (Spend / Leads / CPL / Revenue / ROAS / Profit — always the union, whatever columns are showing) + one `DataTable` with a client-side search box and a per-row **expand** panel. Money is shown as **`RM`**. Redirects to `/manage/facebook` if no Active integration exists. A `?` opens a bilingual explainer of the cohort maths ([`Components/RoasInfoButton.vue`](/resources/js/Components/RoasInfoButton.vue), sibling of `CplInfoButton`); the **CTR / CPC / CPL column headers carry their own `?`** ([`Components/MetricInfoButton.vue`](/resources/js/Components/MetricInfoButton.vue), one parameterised component for the three formulas — each shows the formula, a worked example, and WHY it reads differently from Ads Manager: link-clicks-only denominators for CTR/CPC, our-leads denominator for CPL).
  - **`?view=delivery|return` — one dataset, two column sets.** The controller sends every column once (the rollup computes them all anyway); the **Delivery | Return** segmented control only switches which columns are LOOKED at, client-side, syncing `?view=` via `history.replaceState` with **zero reload**. Delivery = Account / Spend / Impr. / Clicks / CTR / CPC / Leads / CPL; Return = Spend / Leads / CPL / Buyers / Conv. / Revenue / ROAS / Profit. Grain (`?grain=`) and range (`?range=`, 7/30/90/180 days) DO change the dataset, so those go back to the server.
  - **Running rows first (product decision 2026-08-03).** Rows sort `is_delivering DESC → last_spend_date DESC → spend DESC`: what is spending money NOW on top, then history newest-active-first — the order the user asked for ("the running campaign, then the last active"). `is_delivering` = spent money **today or yesterday** (yesterday included on purpose: the hourly sync may not have written today's rows in the early hours, and a badge that flickers off every morning reads as a paused campaign). A **zero-spend day is not delivery** — the `MAX(CASE WHEN spend > 0 …)` keeps it out of `last_spend_date`.
  - **Status badge + settings expand.** Each row joins its `meta_ad_settings` row (by grain level + Meta id): a dedicated **Status column** shows the **Running / Paused / …** badge from `effective_status` (`MetaAdSetting::badge()`; no settings row yet → an honest fallback badge from `is_delivering`) with the "last active {date}" line under it for stopped rows, while the width-capped name column truncates and carries the full name on its hover title — so the numbers keep the room. The expand panel opens with a **rose "Meta flagged this" box** when the row carries review issues (`issues_info` + `ad_review_feedback` flattened by `presentIssues()` — the WHY behind a DISAPPROVED/WITH_ISSUES badge), then shows: the **Learning stage badge** (adset — `LEARNING_STAGES`, where Meta's `FAIL` reads "Learning limited"), budget (minor→major units, CBO noted, + remaining / cap), bid strategy, objective + special-ad-category chips, optimisation → promoted event + billing, **attribution window** ("7d click · 1d view" — the line that explains Ads-Manager-vs-ours gaps), schedule + Meta's own created/edited times, **targeting chips** (adset grain — audiences sized from the catalogue, e.g. *"Webinar attendees 90d (~42k)"*), the ad's **URL-tags line** (amber warning only when a synced template really lacks `{{…id}}` macros), and the **creative card** (ad grain: image resolved through the **permanent `meta_ad_images` hash lookup** with the snapshot URL as fallback + `onerror` text-only degrade, title/body/description from the resolved story spec, carousel/dynamic-variant note, CTA, **"View real ad ↗"** via `preview_link`, and an Instagram permalink when one exists). Unsynced rows say so, with the command to run.
  - **Reads the LOCAL stores only** (`meta_ad_insights` hourly + `meta_ad_settings` 4-hourly) — the old live-fetch-behind-cache path died 2026-07-29. The page shows when each store was last refreshed (clamped: a writer with a skewed clock once left `MAX(updated_at)` in the future, which rendered as *"synced 5 hours from now"* — `syncedAgo()` reads any future stamp as "just now"), and says plainly when nothing has synced.
  - **"Sync now"** (button, `manage-marketing` only): `POST campaigns/sync` → `CampaignsController::sync()` queues **one** [`SyncMetaAdDataJob`](/app/Jobs/Marketing/SyncMetaAdDataJob.php) (insights `--days=3` + the full settings/catalogue sweep). `Cache::add` on `SYNC_FLAG` is atomic, so racing clicks queue exactly one job; the job clears the flag in `finally` (TTL 30 min is the crash backstop) and the page's `syncRunning` prop keeps the button spinning across reloads. The scheduled hourly/4-hourly runs remain the steady state — the button is for "the boss wants it fresh NOW".
  - **The `{{…id}}` macro check looks in BOTH homes.** Meta accepts the tracking macros as URL parameters (**`AdCreative.url_tags`** — where this account keeps them; served ads get them appended to the link at click time) **or** written straight into the creative's destination URL — `hasAdMacros()` scans both stored sources, and the expand row shows the **Destination** link plus the **URL tags** template. Amber warning only when both sources are known and neither carries a macro; unknown ≠ missing. (Write-side, unsubstituted literal `{{campaign.id}}` values posted by someone opening the template URL directly are dropped by `RegisterLeadAction::sanitizeAdTracking()` + retro-cleaned by migration `2026_08_03_100001` — the funnel module's guard; the two protections meet in the middle.)
  ⚠️ The page shows **both lead counts, side by side** (2026-08-04): **"Our leads"** = real CRM registrations (what every rate divides by), and **"Meta leads"** = Meta's own Ads-Manager figure (`meta_ad_insights.meta_leads`, summed like any raw count — the reconciliation number an admin compares against Ads Manager, deliberately NEVER fed into CPL/ROAS). The KPI tile reads "{ours} / {Meta} Meta". The two WILL differ — Meta counts conversions inside its 7-day-click/1-day-view window; ours is the number the business can act on. Historical days show Meta leads = 0 until a `--days=180` re-sync has run.
  > **Was two pages.** Campaign Performance (`/campaigns`) and Ad Return (`/roas`) were both `AdPerformanceService::rollup()` with different columns — Campaigns was literally a subset, and only Roas had the grain switcher. `/manage/marketing/roas` now redirects to `campaigns?view=return` (grain/range query preserved); [`RoasController`](/app/Http/Controllers/Manage/Marketing/RoasController.php) survives as that one-line redirect stub.
- **Campaign Mapping (`/manage/marketing/campaign-mapping`).** A DataTable of **synced, active campaigns** (every objective since 2026-07). An admin **ties** each campaign to its destination — a **Project** (instant-form campaign: webhook leads route into that project's pool) **or** a **Funnel** (traffic campaign driving the funnel's landing page: registrations self-report their ad ids via URL params and group under the funnel) — **XOR**, setting one clears the other. **Three** write actions — **Sync** (pull fresh from Meta), **Assign** (tie/untie) and **Recover leads** (`backfill-leads` — pull the last 90 days straight from each tied form, for submissions whose webhook never arrived) — plus two nudge banners: red = leads that failed to reach the CRM, amber = leads from not-yet-tied campaigns.

## How it works
- **Ad Insights.** `AdsController::index` validates `?range`, loops the fixed `['fmx'=>'FMX','mkt'=>'MKT']` map, and calls `FacebookAdsService::accountInsights()` + `campaignInsights()` per account — both use the **system token** via the service's `get()`.
- **Traffics** calls one service — [`AdPerformanceService::rollup($grain, $since, $until)`](/app/Services/Marketing/AdPerformanceService.php) — so there is exactly one definition of every figure (the rollup also carries `last_spend_date` + `is_delivering` and owns the running-first ordering), then joins `meta_ad_settings` per row and pre-summarises the heavy parts server-side (`CampaignsController::presentSettings()` / `summariseTargeting()` — the raw targeting JSON never rides the page payload). No Graph call sits on the request path.

  > ⚠️ **The cohort revenue/buyer subqueries are RAW SQL, so they must filter `deleted_at` by hand.** Eloquent's SoftDeletes global scope does not reach inside a `DB::table()` subquery: without `AND ph.deleted_at IS NULL` a purchase an admin deleted keeps inflating that ad's revenue and buyer count **forever**, and nothing anywhere fails. Fixed 2026-08-06 — it had been latent because no purchase had ever been deleted. It stopped being theoretical when deleting a payment became a routine correction (a wallet transfer that turned out not to be a sale — see [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md)). Any new raw query over `purchase_histories` inherits the same obligation.

### The one grain everything reads: `meta_ad_insights`

**One row per AD per DAY** ([`src/Marketing/MetaAdInsight.php`](/src/Marketing/MetaAdInsight.php)), refreshed by
[`meta:sync-ad-insights`](/app/Console/Commands/Marketing/SyncMetaAdInsights.php) (Kernel **hourly**, 7-day trailing
window; `--days=180` by hand for the one-off backfill). Campaign, adset, account and any date range are all this
table SUMmed and GROUPed differently — so no two screens can quote different numbers for the same ad.

Four rules it is built on, each of which is a bug if broken:

1. **RAW COUNTS ONLY.** `spend / impressions / clicks / link_clicks / meta_leads` are stored; CTR, CPM, CPC are derived at read
   time from the sums. **A rate cannot be averaged** — a 30-day CTR is total clicks ÷ total impressions, not the mean
   of 30 daily CTRs. Meta returns the ready-made rates; storing them would invite exactly that mistake.
2. **`reach` is never summed across days.** It counts unique PEOPLE; someone who saw the ad on two days is one
   person. It is stored per day (where it is meaningful) and excluded from every roll-up.
3. **A 7-day trailing window, not just yesterday.** Meta RESTATES the past — spend settles and actions keep landing
   for ~72h. The unique `(date, ad_id)` key is what makes a re-sync overwrite rather than duplicate.
4. **Every ad in the account is synced**, not only ads that produced a registration. An ad that burned money and
   returned nothing is invisible to any lead-driven query — and it is the single most useful row a marketer can see.

### How Ad Return counts money (the cohort rule)

A date range selects the **spend**, and the leads that spend bought. Revenue is then **all money those leads have
ever paid**, whenever it arrived.

> July spend 3,000 → the 45 people who registered in July → everything those 45 have paid, *including in September*.

That is the reason for computing this ourselves at all: **Meta attributes inside a 7-day click window and this
business closes over weeks**, so Meta reports many of these sales as zero. Two consequences to expect, both
documented on the page itself:

- **A past period's ROAS keeps rising** — July read in October is not July read in July, because that cohort is
  still buying. The number is being honest, not drifting.
- **These figures will not match Ads Manager.** That gap *is* the feature.

Credit goes to the ad that **brought the person in** (first touch, product decision 2026-07-29) — matching
[`ReportPurchaseToMetaAction`](/app/Actions/Marketing/ReportPurchaseToMetaAction.php), so the ROAS page and what Meta
is told never disagree. The first-touch filter is a **correlated subquery, never a join** (§14): a lead with several
ad touches would otherwise be counted once per ad and their money credited to each.

> ⚠️ **The first-touch rule has to be applied THREE times, not once** (fixed 2026-08-10). `leadAndRevenueRows()` filters
> the outer `lf` rows with it, but the `revenue` and `buyers` correlated subqueries re-derive their own lead set from
> `lead_funnels` (`lf3`) instead of reusing the outer one — so while they were missing it they collected **every** touch
> of that grain id, and a person who arrived through ad A and later re-registered through ad B had their whole lifetime
> revenue counted under **both**. The rule is now built once by a `$firstTouch($alias)` closure and applied to `lf` and
> `lf3` alike. Any new subquery over `lead_funnels` in this service inherits the same obligation — exactly like the
> `ph.deleted_at IS NULL` rule beside it.

> **RETIRED 2026-07-29 — `meta_campaign_spends`.** It stored campaign-grain daily spend and only for campaigns that
> had registrations, so a campaign that spent with zero return did not exist in this system. `SessionAdInsightsService`
> now rolls campaign-day spend up from `meta_ad_insights` (`campaignDaySpend()`), and its **on-demand write-through
> fetch is gone** — the hourly ad-level sync covers every account, so a missing day means the sync has not run rather
> than that nobody asked. The table, model and `meta:sync-ad-spend` command survive one release for comparison but are
> **unscheduled and unread**.
- **Campaign Mapping — read.** `CampaignMappingController::index` (uses `ResolvesListQuery`) group-scopes `meta_campaigns` via `GroupScope::apply`, eager-loads `project`, paginates, and builds ad-account filter options + counts. Extra props: `unmappedLeadCount` (FLG leads with `campaign_id IS NULL`) and `syncFailedCount` (FLG leads with `crm_sync_error IS NOT NULL`).
- **Campaign Mapping — sync.** `sync()` → `MetaCampaignSyncService::syncAll()`: same active-integration iteration, calls `activeCampaignsWithToken()` (ALL ACTIVE campaigns — the lead-objective filter was dropped so traffic/awareness campaigns are tie-able to funnels) per account, upserts each via `MetaCampaignRepository::upsertFromSync()` (unique on `(meta_ad_account_id, meta_campaign_id)`, preserving any existing `project_id`/`event_funnel_id` mark). Also runs daily at 05:00 — see below.
- **Campaign Mapping — destinations.** `assign()` resolves project XOR funnel and calls `MetaCampaignRepository::setDestination()` (setting one clears the other). A **project** tie materialises `flg_campaigns` routing rows (below); a **funnel** tie needs none (landing leads self-report) and tears down any routing rows left from a previous project tie.
- **Campaign Mapping — assign (the glue to FLG).** `assign()` sets the destination via `MetaCampaignRepository::setDestination()` (project XOR funnel — there is **no** `setProject()`), then `MetaCampaignSyncService::materialiseForCampaign()` fetches the campaign's **Instant-Form ids** (`campaignLeadFormIds()`) and `MetaCampaignRepository::materialiseMapping()` upserts one **`flg_campaigns`** routing row per form id (keyed on `meta_form_id`). When the **unchanged FLG webhook** later fires for that form, the lead auto-links to the tied project. Untying soft-deletes those synced routing rows. **Portal-launched `flg_campaigns` rows (`launched_by` set) are never touched.**
- **Campaign Mapping — recover ("Recover leads").** `backfillLeads()` (`POST campaign-mapping/backfill-leads`, `manage-marketing`) is the safety net for Instant-Form submissions whose **webhook never arrived at all** — a lapsed Page subscription, downtime, an expired token during the outage. It group-scopes `flg_campaigns`, asks [`FlgLeadBackfillService::formsFrom()`](/app/Services/FacebookLeadGenerator/FlgLeadBackfillService.php) for the tied form ids, then `run($forms, now()->subDays(90)->getTimestamp())` pulls straight from `/{form_id}/leads` (`FacebookAdsService::formLeadsWithToken`) and ingests anything missing. **90 days**, because Meta's own lead retention is ~90 days — nothing older is recoverable. Deduped on the leadgen id, so re-running never double-counts; with no tied campaigns it short-circuits with an info flash. Same engine as the `flg:backfill-leads` command (see the [connection doc's](/docs/modules_handbook/manage/meta-ads/connection/readMe.md) token-expiry section).
- **Token resolution for materialise** (`materialiseForCampaign`): the campaign's own active-integration token → `FB_AD_ACCESS_TOKEN` → `META_ACCESS_TOKEN` (last-resort fallback).
- **`FacebookAdsService` fails soft.** Every method returns `['ok'=>bool,'error'=>?string,'data'=>array]`, so a bad/expired token never breaks a page. Two transports: `get()` injects the **system token** (`FB_AD_ACCESS_TOKEN`); `getWithToken()` injects a **caller-supplied OAuth token**. Both honour a `ca_bundle` (falls back to `storage/certs/cacert.pem`) — the Laragon/Windows cURL-77 TLS workaround.

## Scheduling
Three commands, all registered in `app/Console/Kernel.php` (**not** `routes/console.php`), all `withoutOverlapping()` and all gated on `FacebookIntegration::where('status', STATUS_ACTIVE)->exists()` so an un-provisioned deploy stays quiet.

| Command | File | Cadence | What it refreshes |
|---|---|---|---|
| `meta:sync-campaigns` | [`SyncMetaCampaigns.php`](/app/Console/Commands/Marketing/SyncMetaCampaigns.php) → `MetaCampaignSyncService::syncAll()` | **daily 05:00 `Asia/Kuala_Lumpur`** | The **campaign catalogue + routing only** — `meta_campaigns` rows and the `flg_campaigns` mapping. No spend, no performance. |
| `meta:sync-ad-insights` | [`SyncMetaAdInsights.php`](/app/Console/Commands/Marketing/SyncMetaAdInsights.php) | **hourly** | `meta_ad_insights` — every ad in every connected account, 7-day trailing window (`--days=180` by hand to backfill). The grain Traffics and session CPL read. |
| `meta:sync-ad-settings` | [`SyncMetaAdSettings.php`](/app/Console/Commands/Marketing/SyncMetaAdSettings.php) | **every 4 hours** | **Five edges per account**: `meta_ad_settings` (campaigns / adsets / ads — status, budgets, bid, targeting, learning stage, review issues, creative) + the two catalogues `meta_custom_audiences` and `meta_ad_images`. Not hourly on purpose: settings are configuration, not restated facts (`--account=` to limit by hand). The image sync earns the cadence twice over — it re-freshens the CDN URLs behind the permanent hashes. |

⚠️ **Traffics and session CPL never call Graph on the request path.** (The separate **Ad Insights** page still does — `AdsController@index` → `accountInsights()` / `campaignInsights()`, live and uncached on every load. It is the one live-fetch surface left.) They read the local stores and hold **no cache** — Traffics instead shows `MetaAdInsight::max('updated_at')` / `MetaAdSetting::max('last_synced_at')` as "synced … ago", and says plainly when nothing has synced. (Until 2026-07-29 the Campaigns page assembled a live Graph result behind a 600 s per-range cache; that path is gone with `FetchMetaCampaignsAction`.)

⚠️ **`meta:sync-ad-spend` is no longer scheduled** — `Kernel.php` carries a "RETIRED 2026-07-29" comment in its place, because the campaign-grain spend store it fed is superseded by the ad-grain `meta_ad_insights` (see the RETIRED note above). The command file survives one release; running it by hand only writes the unread `meta_campaign_spends` table.

## Table & columns — `meta_ad_insights`
The performance grain everything above rolls up from: **one row per ad per day**. Migration `2026_07_29_210001`. Plain model ([`src/Marketing/MetaAdInsight.php`](/src/Marketing/MetaAdInsight.php)) — **no uuid, no blame, no soft delete**: these are re-fetchable facts owned by Meta, not records a person authored. Written only by `meta:sync-ad-insights`. No schema-level FKs (the ids are Meta's, not ours).

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `date` | date (idx) | no | The day these numbers are for, **in the ad account's timezone** (not ours). |
| `account_id` | string(64) (idx) | no | Meta ad account **verbatim as `facebook_ad_accounts.account_id` holds it, whatever shape that is** (in practice `act_…`), because the Campaigns page joins on that value to show the account name. Note this is independent of the URL, which always normalises to exactly one `act_` prefix. The sync deliberately writes the id *it requested*, not the bare one Graph echoes back. (⚠️ the migration's column comment says "without the prefix" and is wrong — trust the code.) |
| `campaign_id` / `adset_id` / `ad_id` | string(64) (idx each) | no | The three levels of Meta's hierarchy on the same row, so any grain is a `GROUP BY`. |
| `campaign_name` / `adset_name` / `ad_name` | string(191) | yes | **Denormalised on purpose.** An ad that spent money and produced NOTHING has no `meta_ad_refs` entry (that table is filled from leads) — and that is exactly the row a marketer needs to read. |
| `spend` | decimal(12,2) | no (dflt 0) | Raw cost for the day. |
| `impressions` | unsignedBigInteger | no (dflt 0) | Times shown. |
| `clicks` | unsignedBigInteger | no (dflt 0) | **All** clicks, including reactions and profile taps. |
| `link_clicks` | unsignedBigInteger | no (dflt 0) | **Outbound link clicks — the CTR a marketer actually means.** `rates()` divides by this one, never by `clicks`. |
| `meta_leads` | unsignedInteger | no (dflt 0) | **Meta's OWN reported lead count** (Ads Manager's `actions` summed over the lead action types; added `2026_08_03_100002`). Shown beside our registrations on Traffics as the reconciliation pair — summable like any raw count, **never** fed into CPL/ROAS. Historical days are 0 until a `--days=180` re-sync. |
| `meta_leads` | unsignedInteger | no (dflt 0) | **Meta's OWN reported lead count** (its `actions` summed over `FacebookAdsService::LEAD_ACTION_TYPES`) — the "leads in Ads Manager" figure, counted inside Meta's attribution window. Added `2026_08_03_100002` for the Funnel Dashboard, which shows it BESIDE our CRM registrations: the two legitimately differ, and reconciling them is the point. Historical days read 0 until re-synced (`--days=180` once). |
| `reach` | unsignedBigInteger | no (dflt 0) | Unique PEOPLE that day. ⚠️ Never summed across days — `summableColumns()` deliberately omits it. |
| `currency` | char(3) (dflt `MYR`) | no | The ad account's billing currency, from Graph's `account_currency` (falls back to `MYR`). Mixed-currency accounts must never be added up blind. |
| `created_at` / `updated_at` | timestamp | yes | `MAX(updated_at)` is what the Campaigns page shows as "last synced". |

Three indexes, each earning its place: **`unique(date, ad_id)`** is what makes a re-sync of a restated day overwrite instead of duplicate; **`(date, campaign_id)`** and **`(date, adset_id)`** are the report's hot path (a date range rolled up one level).

Read it through the model's helpers rather than hand-rolling SQL: `summableColumns()`, `sumSelect()` and `rates()` (`ctr` = `SUM(link_clicks)/SUM(impressions)`, `cpm`, `cpc`) — one definition of every derived figure, which is the whole point of rule 1 above.

## Table & columns — `meta_ad_settings` (NEW 2026-08-03)
The **settings** sibling of `meta_ad_insights`: that table stores what each ad **did** (restated daily facts), this one stores what each ad **is** (configuration — status, budget, bid, targeting, creative). One row per `(level, meta_id)`, all three hierarchy levels in one table (levels `1`/`2`/`3`, same semantics as `meta_ad_refs`); a level only fills the columns that exist for it. Migration `2026_08_03_000001`. Plain model ([`src/Marketing/MetaAdSetting.php`](/src/Marketing/MetaAdSetting.php)) — **no uuid, no blame, no soft delete** (machine-owned facts). Written only by `meta:sync-ad-settings` via `MetaAdSettingRepository::syncLevel()` (chunked bulk upsert, **every synced field refreshed** — unlike `meta_campaigns`, there are no local marks to preserve). Read by the Traffics page's badge + expand row.

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `level` | unsignedTinyInteger | no | `1`=campaign `2`=adset `3`=ad (`MetaAdSetting::LEVEL_*`). Unique with `meta_id`. |
| `meta_id` | string(64) | no | Meta's own object id at that level. |
| `account_id` | string(64) (idx) | no | Owning ad account, **verbatim as `facebook_ad_accounts` holds it** (`act_…`) — the same convention as `meta_ad_insights`, so the two tables can never disagree on id shape. |
| `parent_campaign_id` / `parent_adset_id` | string(64) | yes | Hierarchy context (filled per level; campaign idx only). |
| `name` | string(191) | yes | Display name. |
| `status` | string(40) | yes | What the advertiser set at THIS level. |
| `effective_status` | string(40) | yes | **What is actually happening** (a paused campaign pauses its children) — the badge reads this via `MetaAdSetting::badge()` (`STATUSES` metadata: ACTIVE→emerald "Running", the `*_PAUSED` family→amber, WITH_ISSUES→rose, unknown→slate raw). |
| `objective` / `buying_type` | string | yes | Campaign level. |
| `daily_budget` / `lifetime_budget` / `bid_amount` | unsignedBigInteger | yes | ⚠️ **Meta's raw MINOR units (cents)** — `presentSettings()` divides by 100 for display. With CBO the budget lives on the **campaign** row and the adset rows carry null — an adset with both nulls under a budgeted campaign means exactly that, not "no budget". |
| `bid_strategy` | string(60) | yes | — |
| `optimization_goal` / `billing_event` | string(60) | yes | Adset level. |
| `start_time` / `end_time` | datetime | yes | A campaign's `stop_time` is mapped into `end_time` (Meta names them differently per level). |
| `targeting` | json | yes | Adset only: the **full** targeting spec as Meta returns it. Summarised to chips server-side (`summariseTargeting()`) — the raw JSON never rides the page. |
| `creative` | json | yes | Ad only: `{title, body, image_url, thumbnail_url, cta_type}`. ⚠️ `image_url` is an expiring Meta CDN link — a preview, not an archive. |
| `last_synced_at` | datetime | yes | `MAX(last_synced_at)` is the page's "settings synced … ago". |
| `created_at` / `updated_at` | timestamp | yes | — |

The fetchers deliberately pass **no `effective_status` filter**, so paused objects come back too — a paused campaign is exactly the "last active" row whose settings the page still needs to show.

**Tier-1 expansion (same day):** the table also carries every remaining readable node field — `special_ad_categories` / `spend_cap` / `budget_remaining` / `pacing_type` / `source_campaign_id` (campaign), `promoted_object` / `destination_type` / `attribution_spec` / **`learning_stage_info`** / `frequency_control_specs` / `is_dynamic_creative` (adset), `url_tags` / **`preview_link`** / `conversion_domain` / **`ad_review_feedback`** (ad), plus shared `issues_info` and Meta's own `meta_created_at` / `meta_updated_at` (prefixed — `created_at`/`updated_at` are OUR row's). Migration `2026_08_03_200001`. Three field notes from real data: `learning_stage_info.status = FAIL` means **"Learning limited"**, not failed (`LEARNING_STAGES` translates it); `issues_info` / `ad_review_feedback` only appear on flagged rows (they are the WHY behind the DISAPPROVED / WITH_ISSUES badges); and **`url_tags` lives on the CREATIVE, not the Ad node** — a node GET for ad-level `url_tags` 400s with *"nonexisting field"* (probed against production 2026-08-03), so the column is filled from the `creative{url_tags}` expansion. The first sync requested it at ad level, got nothing back, and briefly concluded "empty account-wide" — wrong: ~2,700 ads carry a full template there (`utm_source=meta…&campaign_id={{campaign.id}}&…`).

## Table & columns — `meta_custom_audiences` + `meta_ad_images` (Tier 2, 2026-08-03)

Two account **catalogues** beside the settings store, same conventions (machine-owned: no uuid/blame/soft-delete; written only by `meta:sync-ad-settings`; chunked bulk upserts via [`MetaCatalogueRepository`](/src/Marketing/Repositories/MetaCatalogueRepository.php)):

- **`meta_custom_audiences`** (migration `2026_08_03_200002`, unique `(account_id, meta_id)`) — the Custom Audience catalogue: `name`, `subtype`, `description`, **`approx_count_lower/upper`** (Meta discloses a range; −1 = withheld, stored as null), `delivery_status` / `operation_status` (json), `retention_days`, Meta timestamps. A targeting spec's inline `{id, name}` refs lack the SIZE and health — this is what turns the chip "3 custom audiences" into *"Webinar attendees 90d (~42k)"* (`sizeLabel()`).
- **`meta_ad_images`** (migration `2026_08_03_200003`, unique `(account_id, image_hash)`) — the image library by **permanent hash**: `url` / `url_128` / `permalink_url`, dimensions, status, Meta timestamps. THE point: a creative's `image_url` snapshot **expires**, the hash never does — `presentSettings()` resolves `creative.image_hash` through this table, whose URLs are re-freshened every sync, so old creatives keep rendering.

## Table & columns — `meta_campaigns`
Meta lead-gen campaigns synced from connected ad accounts, marked with the project each campaign's leads belong to. Source of truth for the Campaign-Mapping page. Key model: `uuid` (HasUuid) + blame; **no soft delete**. Migration `2026_07_22_110001`. Unique `(meta_ad_account_id, meta_campaign_id)`. No schema-level FKs.

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `uuid` | uuid (unique) | no | Public id (URLs / route-model binding). |
| `integration_id` | unsignedBigInteger (idx) | no | → `facebook_integrations.id` (which connection it synced from). |
| `meta_ad_account_id` | string(60) (idx) | no | Meta ad-account the campaign belongs to. |
| `account_name` | string(191) | yes | Ad-account name (display only). |
| `meta_campaign_id` | string(60) | no | Meta's own campaign id. |
| `name` | string(191) | yes | Campaign name (as in Ads Manager). |
| `objective` | string(60) | yes | Meta objective (raw string; no model constants). |
| `effective_status` | string(40) | yes | Meta status (ACTIVE / PAUSED…, raw string). |
| `meta_page_id` | string(60) | yes | Owning Page id, captured from the campaign's ads when tied. |
| `project_id` | unsignedBigInteger (idx) | yes | **Destination A** → `projects.id`; instant-form leads route to this project. XOR with `event_funnel_id`. |
| `event_funnel_id` | unsignedBigInteger (idx) | yes | **Destination B** → `event_funnels.id` (added `2026_07_24_000003`); a traffic campaign driving this funnel's landing page. XOR with `project_id`. |
| `group_id` | unsignedBigInteger (idx) | yes | Agency partition, inherited from the owning integration. |
| `last_synced_at` | datetime | yes | Last time this row was refreshed from Meta. |
| `created_by` | unsignedBigInteger | yes | Blame. |
| `updated_by` | unsignedBigInteger | yes | Blame. |
| `created_at` | timestamp | yes | — |
| `updated_at` | timestamp | yes | — |

> The connection module's three tables (`facebook_integrations`, `facebook_pages`, `facebook_ad_accounts`) are documented in [Facebook Connection](/docs/modules_handbook/manage/meta-ads/connection/readMe.md). The routing rows written on tie live on **`flg_campaigns`** (owned by FLG, keyed by `meta_form_id`).

## Table & columns — `meta_ad_refs` (the lazy name lookup)
Names for Meta ad-hierarchy ids seen on leads, so the Lead → Attribution tab shows "name (id)" and the owning **ad account** instead of naked numbers. Filled lazily by `ResolveMetaAdRefsJob` — dispatched (after-commit, fail-soft) whenever a lead arrives with an `ad_id` (funnel landing or FLG webhook); ONE Graph call resolves all three levels. O(distinct ads on leads), no catalogue sync. Migration `2026_07_24_000002`; unique `(level, meta_id)`; no uuid/soft-delete.

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `level` | unsignedTinyInteger | no | `1`=campaign `2`=adset `3`=ad (`MetaAdRef::LEVEL_*`). |
| `meta_id` | string(60) | no | Meta's object id at that level. Unique with `level`. |
| `name` | string(191) | yes | Resolved display name. |
| `parent_campaign_id` | string(60) (idx) | yes | For adset/ad rows. |
| `parent_adset_id` | string(60) | yes | For ad rows. |
| `account_id` | string(60) (idx) | yes | Owning ad account (digits, no `act_` prefix — matches `facebook_ad_accounts.account_id` after normalisation). |
| `status` | string(40) | yes | `effective_status` snapshot. |
| `resolved_at` | datetime | yes | Last successful fetch; stale after `STALE_AFTER_DAYS` (30) → re-resolved on next sighting. |
| `failed_at` / `fail_count` | datetime / unsignedTinyInteger | yes / no | Back-off — a dead id stops retrying after `MAX_FAILURES` (3). |
| `created_by` / `updated_by` | unsignedBigInteger | yes | Blame. |
| `created_at` / `updated_at` | timestamp | yes | — |

Reader: `LeadsController::withAdRefNames()` enriches the **detailed** lead transform (Show / quick modal) with `campaign_name` / `adset_name` / `ad_name` / `ad_account_id` / `ad_account_name` (falling back to the synced `meta_campaigns` catalogue for campaign-level names); the index rows stay cheap.

## `FacebookAdsService` methods (`src/Marketing/Services/FacebookAdsService.php`)

| Method | Graph endpoint | Token | Purpose |
|---|---|---|---|
| `isConfigured()` | — | — | True if a system token + at least one of FMX/MKT ids are set. |
| `accountId($key)` | — | — | Resolve `fmx`/`mkt` → ad-account id. |
| `accountInsights($acct,$preset)` | `GET {acct}/insights` (`level=account`) | **system** | KPI row for Ad Insights. |
| `campaignInsights($acct,$preset)` | `GET {acct}/insights` (`level=campaign`, limit 25) | **system** | Top-25 campaign table for Ad Insights. |
| `tokenCandidates()` | — | — | **The token ladder** every OAuth caller walks: each ACTIVE integration's token (**system-user first**, then newest), then `FB_AD_ACCESS_TOKEN`, then `META_ACCESS_TOKEN`. Used by `ResolveMetaAdRefsJob`, `SessionAdInsightsService` and `SyncMetaAdSpend`. |
| `campaignRowsWithToken($acct,$token,$preset)` | `GET {acct}/insights` (limit 200, with `actions`) | **OAuth** | ⚠️ **RETIRED with the old live Campaigns fetch.** Its only caller is `FetchMetaCampaignsAction`, itself retired (below) — the Campaigns page reads `meta_ad_insights`. Do not wire a new page to it. |
| `formLeadsWithToken($form,$token,$since,$cap)` | `GET {form_id}/leads` (pages through `paging.next`, cap 5000) | **OAuth** | Lead **recovery**: pulls an Instant Form's submissions directly, for the "Recover leads" backfill and `flg:backfill-leads`. |
| `activeCampaignsWithToken($acct,$token)` | `GET {acct}/campaigns` (ACTIVE, all objectives) | **OAuth** | Active campaigns for Mapping sync (renamed from `activeLeadCampaignsWithToken`; objective filter dropped 2026-07). |
| `campaignLeadFormIds($campaign,$token)` | `GET {campaign}/ads` (traverse creatives) | **OAuth** | Distinct Instant-Form ids + owning page_id → `flg_campaigns.meta_form_id`. |
| `adHierarchyWithToken($adId,$token)` | `GET {ad_id}` (node, `adset{id,name},campaign{id,name}`) | **OAuth/env** | One call resolves ad + adset + campaign names + owning ad account → `meta_ad_refs` (via `ResolveMetaAdRefsJob`). |
| `campaignDailySpendWithToken($campaign,$since,$until,$token)` | `GET {campaign}/insights` (`time_increment=1`) | **OAuth/env** | ⚠️ RETIRED — fed the old `meta_campaign_spends` store; only `meta:sync-ad-spend` still calls it. |
| `adDailyInsightsWithToken($acct,$since,$until,$token)` | `GET act_{acct}/insights` (`level=ad`, `time_increment=1`, **pages through `paging.next`**) | **OAuth** | Every ad's daily performance → `meta_ad_insights`. The grain behind Traffics and session CPL. |
| `campaignSettingsWithToken($acct,$token)` | `GET act_{acct}/campaigns` (limit 200, pages) | **OAuth** | Campaign settings → `meta_ad_settings` level 1: status, objective, buying type, budgets + **spend_cap / budget_remaining**, bid strategy, pacing, **special_ad_categories**, source campaign, **issues_info**, schedule + Meta's own created/updated times. |
| `adsetSettingsWithToken($acct,$token)` | `GET act_{acct}/adsets` (limit **100** — `targeting` is heavy; pages) | **OAuth** | Adset settings → level 2: the **full targeting spec**, optimisation goal, billing event, bid amount, **promoted_object / destination_type / attribution_spec / `learning_stage_info`** (Learning limited!), frequency caps, dynamic-creative flag, issues. |
| `adSettingsWithToken($acct,$token)` | `GET act_{acct}/ads` (`creative{…}` nested expansion, limit 100, pages) | **OAuth** | Ad settings → level 3: **url_tags** (the macro template), **preview_shareable_link**, conversion domain, issues + **ad_review_feedback** (why disapproved), and the creative **resolved by `mapCreative()`**. |
| `customAudiencesWithToken($acct,$token)` | `GET act_{acct}/customaudiences` (limit 200, pages) | **OAuth** | The Custom Audience catalogue (name, subtype, **size range**, delivery health) → `meta_custom_audiences`. |
| `adImagesWithToken($acct,$token)` | `GET act_{acct}/adimages` (limit 200, pages) | **OAuth** | The image library by **permanent `hash`** → `meta_ad_images`; every sync re-freshens the CDN URLs. |

The settings/catalogue fetchers share one protected transport, `pagedEdgeWithToken()` — the same loop shape as `adDailyInsightsWithToken` (`Str::start`/`Str::after` prefix normalisation, `Http::timeout(60)` + ca_bundle, 200-page guard, partial rows returned on failure) — and normalise Graph's ISO timestamps via `graphDateTime()`. They pass **no `effective_status` filter**, so paused objects are synced too.

**`mapCreative()` — why the first sync came back "empty".** Modern creatives keep their text in `object_story_spec` (link/video data) or `asset_feed_spec` (dynamic-creative variants); the legacy top-level `title`/`body` are null on most of them. The resolver walks legacy → story spec → first asset variant for every display field (title, body, description, link, CTA), and extracts **`image_hash`** — the permanent key into `meta_ad_images`, which outlives the expiring CDN `image_url` — plus video id, IG permalink, carousel cards (capped at 10) and dynamic-variant **counts**. The RAW specs are deliberately not stored (an asset_feed_spec with ten variants runs to tens of KB per ad).

Constants: `LEAD_ACTION_TYPES` (the 4 Meta `actions` summed into a lead count), `LEAD_OBJECTIVES` (`OUTCOME_LEADS` + `LEAD_GENERATION`, both accepted post-ODAX rename).

## Config / env (`config/services.php`)
- **`services.facebook_ads`** (Ad Insights + the system-token fallback): `FB_AD_APP_ID`, `FB_AD_APP_SECRET`, **`FB_AD_ACCESS_TOKEN`**, `FB_AD_API_VERSION` (default `v25.0`), `FB_AD_ACCOUNT_FMX`, `FB_AD_ACCOUNT_MKT`, optional `FB_AD_CA_BUNDLE`.
- **`services.meta.access_token`** (`META_ACCESS_TOKEN`) — only the tertiary fallback in `materialiseForCampaign()`.

## Permissions (`src/Auth/Permission.php`)
A **plain module pair** (not scoped) — separate from the connection module's `*-meta` family:

| Constant | String | Grants |
|---|---|---|
| `VIEW_MARKETING` | `view-marketing` | Open the merged Traffics page (and the `roas` redirect). Ad Insights + the Campaign-Mapping list additionally require a Meta-account level (`viewMetaAny()`, 2026-08-12). |
| `MANAGE_MARKETING` | `manage-marketing` | Campaign-Mapping **sync** + **assign** (tie campaign → project/funnel) + **backfill-leads** ("Recover leads"). |

`assign()` also re-checks `GroupScope::allows` / `allowsShared` server-side (403), beyond the route gate.

## Two revenues, and why both are on the page (2026-09-16)

The summary now carries **Attributed revenue** and **Total revenue** side by side, because they answer different questions and only one of them is "what did we make".

- **Attributed revenue** (unchanged) is the lifetime spend of leads who **registered inside the window** and are first-touch credited to one of these ads. It is the right numerator for ROAS and it stays the only basis ROAS is computed on.
- **Total revenue** is every `ACTIVE` receipt banked in the window, whatever brought it in.

The gap is not small and not a bug: over the last 30 days the page reported **RM 600** attributed against **RM 4,887** actually banked. A customer who registered in March and paid this week belongs to neither this window's cohort nor any ad still running, so attributed revenue cannot see them — correctly, for ROAS, and uselessly for a P&L.

**Profit is now `total revenue − spend`**, and the tile says `all sales − spend` under it with the attributed figure beneath. ⚠️ That label is the point: a number called "Profit" sitting on an ad table invites the reader to credit every sale in the business to the ads, which is exactly what it does NOT mean. Ad spend is only part of the cost base, and most of that revenue has causes other than these campaigns. The attributed figure is kept visible so the two can never quietly collapse into one number.

## Calendar windows beside the rolling ones

`?from=&to=` sits alongside `?range=`, and **explicit dates win**. The rolling presets (7/30/90/180 days) are the right shape for *"how are the ads doing lately"* and the wrong one for *"how did August go"* — a month-end review is the question this page gets asked most, and a rolling 30 days can never answer it. So both exist; neither replaces the other.

- **The dates ARE the state.** No `period=this_month` token — *This month* / *Last month* set `from`/`to`, and the page decides which button is selected by comparing them back. A token would let a shared link mean something different tomorrow.
- **Going back to a rolling preset clears the dates explicitly**, or they would win over it on the very next request.
- **The dates survive a grain or view change**, so switching Campaign → Ad does not throw the reader back into a rolling window mid-analysis.
- **A backwards range is swapped, not honoured** — it is a typo, and an empty page is a worse answer than the obvious one.

Verified against the ledger: September 2026 → RM 1,300 over RM 9,833 spend; August → RM 19,721 over RM 38,632.

## Related files
**Controllers** — [`app/Http/Controllers/Manage/Marketing/AdsController.php`](/app/Http/Controllers/Manage/Marketing/AdsController.php) · [`CampaignsController.php`](/app/Http/Controllers/Manage/Marketing/CampaignsController.php) (**the merged Traffics page** — `RANGES` 7/30/90/180, `VIEWS` delivery/return, grain from `AdPerformanceService::GRAINS`, the union `totals()`, and the settings join: `presentSettings()` + `summariseTargeting()` + `label()`) · [`RoasController.php`](/app/Http/Controllers/Manage/Marketing/RoasController.php) (⚠️ a one-line **redirect stub** since 2026-08-03 → `campaigns?view=return`, query preserved; delete once old links die out) · [`CampaignMappingController.php`](/app/Http/Controllers/Manage/Marketing/CampaignMappingController.php) (`index`, `sync`, `assign`, `backfillLeads`)
**Form Requests** — `app/Http/Requests/Manage/Marketing/CampaignMapping/AssignRequest.php` (`campaign` exists in `meta_campaigns.uuid`; `project_uuid` **and** `funnel_uuid` both nullable — the XOR destination) · `QueryRequest.php` (filters `search` / `tied` / `account`)
**Lead recovery** — [`app/Services/FacebookLeadGenerator/FlgLeadBackfillService.php`](/app/Services/FacebookLeadGenerator/FlgLeadBackfillService.php) (`formsFrom`, `run`) — behind the page's "Recover leads" button and the `flg:backfill-leads` command
**Action** — [`app/Actions/Marketing/FetchMetaCampaignsAction.php`](/app/Actions/Marketing/FetchMetaCampaignsAction.php) ⚠️ **RETIRED / no caller.** It was the Campaigns page's live Graph fetch behind a 600 s cache; `CampaignsController` now reads `meta_ad_insights` and nothing references this class except a `{@see}` in `MetaCampaignSyncService`. Delete on the next cleanup — do not wire new pages to it.
**Service (sync)** — [`app/Services/Marketing/MetaCampaignSyncService.php`](/app/Services/Marketing/MetaCampaignSyncService.php) (`syncAll`, `materialiseForCampaign`)
**Service (Graph)** — [`src/Marketing/Services/FacebookAdsService.php`](/src/Marketing/Services/FacebookAdsService.php)
**Model / Repository** — [`src/Marketing/MetaCampaign.php`](/src/Marketing/MetaCampaign.php) (`project()`, `funnel()`, `integration()`, `isTied()`) · [`src/Marketing/Repositories/MetaCampaignRepository.php`](/src/Marketing/Repositories/MetaCampaignRepository.php) (`upsertFromSync`, `setDestination` (XOR), `materialiseMapping`, `removeMapping`) · `src/Marketing/Facades/MetaCampaignRepository.php`
**Ad-ref lookup** — [`src/Marketing/MetaAdRef.php`](/src/Marketing/MetaAdRef.php) · [`src/Marketing/Repositories/MetaAdRefRepository.php`](/src/Marketing/Repositories/MetaAdRefRepository.php) (`upsertHierarchy`, `markFailed`) · `src/Marketing/Facades/MetaAdRefRepository.php` · [`app/Jobs/Marketing/ResolveMetaAdRefsJob.php`](/app/Jobs/Marketing/ResolveMetaAdRefsJob.php) (token chain: active integrations system-user-first → `FB_AD_ACCESS_TOKEN` → `META_ACCESS_TOKEN`)
**Session CPL** — [`app/Services/Marketing/SessionAdInsightsService.php`](/app/Services/Marketing/SessionAdInsightsService.php) (per-session breakdown `forEvent($event, $grain)` + DB-only bulk `cplForEvents` for the funnel Sessions tab; daily-proportional spend allocation over the per-join ad snapshot on `event_registrations` — see the [Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) doc)

> **The grain switch (2026-08-10) — campaign | adset | ad, and why it is a SWITCH and never a tree.**
> `forEvent()` takes a grain from the same vocabulary as `AdPerformanceService::GRAINS` (a test pins the two lists identical). The allocation stays **conserved at every grain** — the shares handed to all sessions on a day never exceed what was spent — because the numerator and the denominator move to the finer key *together*. But one session's share legitimately **changes** between grains:
> > Campaign C spends 100 on day D, all of it on ad A. Ad A produced one registration, for session S; campaign C produced five that day.
> > → campaign grain: S gets `100 × 1/5` = **20**. Ad grain: S gets `100 × 1/1` = **100**.
>
> The finer grain is the more truthful allocation *and* the noisier one. A campaign → adset → ad **tree** would claim the children sum to the parent; they do not, so the UI re-queries on a segmented control instead (`?grain=`, validated by `AdInsightsRequest`). Rows under `SMALL_SAMPLE_LEADS` (5) are flagged so nobody budgets off a two-lead CPL.
>
> ⚠️ **Every grain needs its own "no id" bucket.** `RegisterLeadAction::sanitizeAdTracking()` nulls each tracking field **independently**, so a registration can carry an `ad_id` with **no** `campaign_id` — production has one. Ad-grain rows are therefore NOT a subset of campaign-grain rows, and reading one grain's unattributed count as another's silently loses people.
>
> ⚠️ **The Ads tab's headline tiles do NOT follow the grain** — deliberately the opposite of the Traffics page, whose tiles total the visible rows. They read a `headline` block produced by `cplForEvents()`, i.e. the very method the funnel Sessions tab and the Funnel Dashboard call, so those three screens cannot quote different numbers for one session however the table underneath is sliced. The table's own per-grain subtotal is shown separately and labelled as such. Pinned by `test_the_headline_tiles_never_move_when_the_grain_does`.
>
> Names / status badges / creative / preview links come from the shared [`MetaAdLabelResolver`](/app/Services/Marketing/MetaAdLabelResolver.php) (extracted from `EventsController` the same day, so the Registrations roster and the Ads breakdown can never disagree about what an ad is called). It reads `meta_ad_refs` first — the lazy store that follows leads — then falls back to `meta_ad_settings` and the `meta_campaigns` catalogue, which between them know objects that produced no lead at all. ⚠️ `MetaAdSetting::badge()` is **static and takes the status string**; there is no `status_label` / `status_color` column.
>
> **A session's PROMOTION spend is a second, separate figure** (2026-08-11, `promotionSpendForEvents()`): total spend over the window the ads were pointing at that session, derived from the slot schedule by [`SessionPromotionWindow`](/src/Event/Support/SessionPromotionWindow.php). It exists because CPL's window is derived from **registrations** — a day the ads ran and nobody signed up has no share to allocate and is invisible (RM 213.61 of real spend on the production snapshot). It is **never divided into anything**, and it is not a settable window for CPL: widening the CPL range changes nothing, narrowing it silently lowers CPL. Full reasoning in the [Funnels handbook](/docs/modules_handbook/manage/events/funnels/readMe.md).
>
> Migration `2026_08_10_100001` adds `(ad_id, created_at)` + `(adset_id, created_at)` to `event_registrations` — the finer grains run the same range-scoped GROUP BY the campaign column was already indexed for. · spend comes from `meta_ad_insights`, rolled campaign×day by `SessionAdInsightsService::campaignDaySpend()`; when a needed day is missing it returns partial data, or the plain error *"No stored ad spend for these days yet — run `meta:sync-ad-insights`"* — there is **no on-demand write-through fetch** any more (removed 2026-07-29 with `meta_campaign_spends`; [`src/Marketing/MetaCampaignSpend.php`](/src/Marketing/MetaCampaignSpend.php) + `SyncMetaAdSpend.php` still exist but are unscheduled and unread) · endpoint `GET manage/events/{id}/ad-insights` (`EventsController@adInsights`) · UI `resources/js/Pages/Manage/Events/Partials/Tabs/AdsTab.vue` + the Sessions-tab CPL column
**Commands** — [`app/Console/Commands/Marketing/SyncMetaCampaigns.php`](/app/Console/Commands/Marketing/SyncMetaCampaigns.php) (`meta:sync-campaigns`) · [`SyncMetaAdSettings.php`](/app/Console/Commands/Marketing/SyncMetaAdSettings.php) (`meta:sync-ad-settings`) — both scheduled in `app/Console/Kernel.php`
**Frontend** — [`resources/js/Pages/Manage/Marketing/Ads.vue`](/resources/js/Pages/Manage/Marketing/Ads.vue) · [`Campaigns.vue`](/resources/js/Pages/Manage/Marketing/Campaigns.vue) (the merged Traffics page: Delivery|Return toggle, grain + range controls, client-side search, status badges, settings expand panel; `Roas.vue` was **deleted** with the merge) · [`CampaignMapping.vue`](/resources/js/Pages/Manage/Marketing/CampaignMapping.vue) · nav in `resources/js/Layouts/ManageLayout.vue`
**Config** — [`config/services.php`](/config/services.php) (`facebook_ads`, `meta` blocks) · `.env` `FB_AD_*` (gitignored) · `storage/certs/cacert.pem` (dev TLS)
**Ad performance store** — [`src/Marketing/MetaAdInsight.php`](/src/Marketing/MetaAdInsight.php) (`meta_ad_insights`, one row per ad per day) · sync [`app/Console/Commands/Marketing/SyncMetaAdInsights.php`](/app/Console/Commands/Marketing/SyncMetaAdInsights.php) (`meta:sync-ad-insights`, Kernel hourly) · roll-up [`app/Services/Marketing/AdPerformanceService.php`](/app/Services/Marketing/AdPerformanceService.php) (grains, cohort revenue, first-touch, `last_spend_date` + `is_delivering` + the running-first ordering) · page [`Campaigns.vue`](/resources/js/Pages/Manage/Marketing/Campaigns.vue) · explainer [`Components/RoasInfoButton.vue`](/resources/js/Components/RoasInfoButton.vue) · tests `tests/Feature/Marketing/AdPerformanceTest.php` (roll-up + cohort maths + delivering-first ordering) · `tests/Feature/Marketing/SyncMetaAdInsightsTest.php` (`act_` prefix normalisation, raw counts stored as given, re-syncing a day overwrites rather than duplicates, one failing account does not abort the run)
**Ad settings store** — [`src/Marketing/MetaAdSetting.php`](/src/Marketing/MetaAdSetting.php) (`meta_ad_settings`, one row per level+object; `LEVEL_*`, `STATUSES`, `LEARNING_STAGES`, `badge()`, `learningBadge()`, `levelForGrain()`) · [`src/Marketing/Repositories/MetaAdSettingRepository.php`](/src/Marketing/Repositories/MetaAdSettingRepository.php) (`syncLevel` — chunked bulk upsert; ⚠️ its update-column whitelist must gain every new column) · `src/Marketing/Facades/MetaAdSettingRepository.php` · sync [`app/Console/Commands/Marketing/SyncMetaAdSettings.php`](/app/Console/Commands/Marketing/SyncMetaAdSettings.php) (`meta:sync-ad-settings`, Kernel every 4h) · tests `tests/Feature/Marketing/SyncMetaAdSettingsTest.php` (prefix, three levels, json round-trip, story-spec creative resolution, catalogues, idempotence, fail-soft)
**Catalogues** — [`src/Marketing/MetaCustomAudience.php`](/src/Marketing/MetaCustomAudience.php) (`sizeLabel()`) · [`src/Marketing/MetaAdImage.php`](/src/Marketing/MetaAdImage.php) · [`src/Marketing/Repositories/MetaCatalogueRepository.php`](/src/Marketing/Repositories/MetaCatalogueRepository.php) (`syncAudiences`, `syncImages`) · `src/Marketing/Facades/MetaCatalogueRepository.php`

**Migrations** — `database/migrations/2026_08_03_000001_create_meta_ad_settings_table.php` · `2026_08_03_200001_add_detail_fields_to_meta_ad_settings.php` (Tier-1 fields) · `2026_08_03_200002_create_meta_custom_audiences_table.php` · `2026_08_03_200003_create_meta_ad_images_table.php` · `2026_07_29_210001_create_meta_ad_insights_table.php` · `2026_07_22_110001_create_meta_campaigns_table.php` · `2026_07_24_000002_create_meta_ad_refs_table.php` · `2026_07_24_000003_add_event_funnel_id_to_meta_campaigns_table.php` · `2026_07_24_000004_add_ad_attribution_to_event_registrations.php` · `2026_07_24_000005_create_meta_campaign_spends_table.php` (⚠️ retired 2026-07-29 — table still present, nothing reads it)
**Tests** — `tests/Feature/Marketing/TrafficsPageTest.php` (merged page props, `?view=` validation, roas redirect, running-first order, settings join, no-integration bounce) · `tests/Feature/Marketing/CampaignFunnelTieTest.php` · `tests/Feature/Marketing/MetaAdRefResolutionTest.php` · `tests/Feature/Marketing/SessionAdInsightsTest.php` · `tests/Feature/Main/LandingAttributionTest.php` · `tests/Feature/FacebookLeadGenerator/FlgPerLeadAdAttributionTest.php`
**Routes** — [`routes/web.php`](/routes/web.php) — the `Route::prefix('marketing')->name('manage.marketing.')` group (`ads`, `campaigns` — the merged Traffics page, **`campaigns.sync`** — the Sync-now POST, `roas` — the redirect stub, then the nested `campaign-mapping.` → `index`, `sync`, `backfill-leads`, `assign`). The whole group sits behind `permission:view-marketing`; `ads` and the whole `campaign-mapping.` group add `permission:viewMetaAny()` (2026-08-12 — the integration side needs a Meta-account level), and the four POSTs each add `permission:manage-marketing` on top.
**Job** — [`app/Jobs/Marketing/SyncMetaAdDataJob.php`](/app/Jobs/Marketing/SyncMetaAdDataJob.php) (the Sync-now worker: insights `--days=3` + settings/catalogues, clears `SYNC_FLAG` in `finally`)

**See also:** [Facebook Connection](/docs/modules_handbook/manage/meta-ads/connection/readMe.md) (OAuth + the token this borrows) · `src/FacebookLeadGenerator/*` (FLG — `flg_campaigns`, `flg_leads`, the `/webhooks/flg-meta` ingress; CRM promotion prefers the webhook's **per-lead** `ad_id`/`adgroup_id` from `flg_leads.raw['meta']` over the campaign snapshot) · [Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md) · [Leads](/docs/modules_handbook/manage/leads/readMe.md) (`LeadFunnel` attribution — sources consolidated 2026-07 to Funnel / Meta Lead Gen / Owner Listing / Property Match / Other) · [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) (the ad-URL contract feeding funnel attribution)
