> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner module

# Partner Module — GHL Affiliate Manager integration (FUND-2039 → FUND-2042, FUND-2046)

Status: **implemented.** Operator/admin surfaces and their shared API are gated by
`NEXT_PUBLIC_PARTNER_MODULE_ENABLED`. Scheduled cache sync additionally requires
`GHL_AFFILIATE_SYNC_ENABLED`; installed-location discovery is primary and
`GHL_AFFILIATE_SYNC_LOCATION_IDS` is additive emergency coverage.
This is an internal engineering note and is not part of the Mintlify docs site
(`docs/docs.json`).

Ticket map: **FUND-2039** = Phase 1 (sync + cache), **FUND-2040** = Phase 2 (linking +
admin UI + feature flag), **FUND-2041** = Phase 3 (workflow enrollment bridge),
**FUND-2042** = Phase 4 (PDF report + scheduled email), and **FUND-2046** =
location-scoped operator access.

## What it does

The Partner Module gives operators a location-scoped `/partners` surface and the
internal MFM team a cross-location `/admin/partners` surface. Both show the CRM's
**Affiliate Manager** partners (e.g. the "Funding Partners" campaign), which CRM
leads/clients each partner referred, and support enrolling new partners. The admin
surface also configures periodic performance reports.

It is built entirely on top of the CRM's Affiliate Manager. The public Affiliate
Manager API is **read-only** — you cannot create affiliates, campaigns, or
enrollments through it (campaign create/get/update/delete return `404`, per the
AGENTS.md learned fact). So the module *reads* affiliate/commission/payout data on
a schedule, *derives* the rest at query time, and does *writes* (enrollment) by
adding contacts to pre-built CRM **Workflows**, never by calling Affiliate Manager
write endpoints.

> **Terminology.** User-facing copy says **CRM**, never GHL/GoHighLevel (Locked
> Decision). Backend code and this internal doc say GHL. "GHL API version" below
> refers to the `Version` request header.

## Critical identity distinction

Two unrelated "partner" concepts share the word. Do not cross the streams:

| Concept                           | Identity key              | Table                 | Used by               |
| --------------------------------- | ------------------------- | --------------------- | --------------------- |
| **GHL Affiliate Manager partner** | `ghlAffiliateId` (string) | `ghlAffiliates`       | This module           |
| **Prospecting partner**           | `prospectingPartners._id` | `prospectingPartners` | Prospecting subsystem |

Everything in the Partner Module keys on `ghlAffiliateId`. `analyticsScheduledReports.partnerId`
stores a `ghlAffiliateId`, **not** a `prospectingPartners._id`
([`convex/prospecting/affiliateLinking.ts`](../../convex/prospecting/affiliateLinking.ts) L8-L10).

## Architecture

```mermaid theme={null}
flowchart TB
  Flag["NEXT_PUBLIC_PARTNER_MODULE_ENABLED\n(operator/admin UI + shared API, build-time)"]
  Kill["GHL_AFFILIATE_SYNC_ENABLED\n(Convex deployment)"]

  subgraph P1 [Phase 1 · sync + cache · FUND-2039]
    GHLAPI["GHL Affiliate Manager API v3\n(read-only)"]
    Sync["runAffiliateSync\n(internalAction, hourly cron)"]
    Cache[("ghlAffiliates / ghlAffiliateCampaigns\nghlAffiliateCommissions / ghlAffiliatePayouts")]
    GHLAPI --> Sync --> Cache
  end

  subgraph P2 [Phase 2 · linking + UI · FUND-2040]
    Link["affiliateLinking\n(query-time resolution)"]
    API1["Shared affiliate-partners API\n/admin + operator aliases"]
    AdminUI["/admin/partners\n(cross-location, flagged)"]
    OperatorUI["/partners\n(own location, flagged)"]
    Cache --> Link --> API1
    API1 --> AdminUI
    API1 --> OperatorUI
  end

  subgraph P3 [Phase 3 · enrollment · FUND-2041]
    WF["ghlWorkflowLookup"]
    Enroll["enrollContactInCampaign\n(internalAction)"]
    Workflows["GHL Workflows (SDK)\nPartner Enroll — {Campaign}"]
    API1 --> Enroll --> WF --> Workflows
    Workflows -.->|"CRM workflow creates affiliate"| GHLAPI
  end

  subgraph P4 [Phase 4 · reporting · FUND-2042]
    Report["partnerReport.run"]
    Sched["partnerReporting + scheduleCron\n(15-min dispatcher)"]
    Email["emailReportNode"]
    PDF["pdfRenderer"]
    API2["/api/admin/partner-reporting"]
    Cache --> Report
    Sched --> Email --> PDF
    Email --> Report
    API2 --> Sched
  end

  Kill -. gates .-> Sync
  Flag -. gates .-> AdminUI
  Flag -. gates .-> API2
```

## Feature flag and kill switch

Two independent switches, on purpose:

* **`NEXT_PUBLIC_PARTNER_MODULE_ENABLED`** ([`lib/partnerModuleFlag.ts`](../../lib/partnerModuleFlag.ts))
  gates the operator/admin pages, shared affiliate-partners API, and reporting API.
  Only the exact string `"true"` enables them. `NEXT_PUBLIC_*` is inlined at build time,
  so flipping it requires a redeploy.
* **`GHL_AFFILIATE_SYNC_ENABLED`** must be `"true"` for scheduled sync.
  Installed-location discovery is primary. `GHL_AFFILIATE_SYNC_LOCATION_IDS` is
  an additive emergency list for locations discovery misses.

The cron is registered but no-ops while scheduled sync is disabled.

## Session actor auth pattern

Marketplace-iframe auth means `ctx.auth.getUserIdentity()` is `undefined` in these
routes. The module uses the repo's actor pattern instead:

```
Browser → authenticatedFetch → Next.js POST route → getVerifiedActor (fm_session cookie
  or Bearer) → getConvexAdminClient() → internal.* fn with { actor }
  → canAccessPartnerModuleLocation(actor, locationId) or isAdminActor(actor)
```

`canAccessPartnerModuleLocation`
([`convex/lib/partnerModuleAccess.ts`](../../convex/lib/partnerModuleAccess.ts))
allows a normal operator to access only the verified session location and allows
staff/admin actors to switch locations. Unauthorized internal calls return `null`,
which the route maps to `403`. The shared affiliate-partners route and the
admin-only partner-reporting route are `POST`-only with operation-dispatch bodies.

***

## Phase 1 — Sync + cache (FUND-2039)

### Cache schema

Four tables in [`convex/schemas/commissions.ts`](../../convex/schemas/commissions.ts). Shapes were
originally doc-inferred; **live-verified 2026-07-12** against the FM validation
location `oE9ILHco0XXZUu79wAA0` (read-only probe — see "Live verification"
below). Every normalized column except the keys is optional and `rawData`
remains the source of truth.

| Table                     | Upsert key (index)       | Notes                                                                                                                                                                         |
| ------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ghlAffiliates`           | `by_location_affiliate`  | `contactId` (top-level) is the Phase 2 join key; also `by_contact`, `by_email`. Provider id is `_id`                                                                          |
| `ghlAffiliateCampaigns`   | `by_location_campaign`   | **Derived** from embedded refs — GHL has no list-campaigns endpoint. `enrollmentWorkflowId` is staff-set only                                                                 |
| `ghlAffiliateCommissions` | `by_location_commission` | `contactId` = referred CRM contact, read from `customer.contactId` (NOT `customer._id`); `by_location_contact` powers Phase 2 lookup. `amount` verified **whole USD dollars** |
| `ghlAffiliatePayouts`     | `by_location_payout`     | also `by_location_affiliate`. `amount` verified **whole USD dollars**                                                                                                         |

### Live verification (2026-07-12, location `oE9ILHco0XXZUu79wAA0`)

Read-only `GET` probes returned HTTP 200 on all three list endpoints — the
scope **is** granted on this location (the earlier "token lacks
`affiliate-manager.readonly`" note is stale for it). Confirmed:

* **Envelope:** `{ <resource>: [...], meta: { count }, traceId }`. `meta.count`
  is a total available for truncation checks (not yet consumed by the sync).
* **Ids:** records use `_id` (not `id`); the mappers already prefer `_id`.
* **Amounts:** whole USD dollars, not cents (commission `amount:100`,
  `commissionAmount:20`, `commissionType:"percentage"`; payout `amount:20`).
  The report/PDF/UI format them directly as currency — correct.
* **Referred contact:** lives at `commission.customer.contactId` (the CRM
  contact), while `commission.customer._id` is the affiliate-manager
  customer-record id. Storing `_id` breaks the Phase 2 CRM join — fixed so the
  sync reads `customer.contactId`.
* **Campaign refs:** commissions carry `campaign:{id,name}`; payouts carry a
  bare `campaign` NAME plus `campaignId` — the name→id canonicalization
  collapses them to one campaign row, as designed.
* **Pagination:** `skip`+`limit` returns distinct pages (verified `skip=0` vs
  `skip=1`).

### API client — [`convex/lib/ghlAffiliateApi.ts`](../../convex/lib/ghlAffiliateApi.ts)

Raw `fetch` (no SDK — `@gohighlevel/api-client@3.0.0` has zero affiliate-manager
coverage). `Version` header `v3`; Bearer is the location access token. Constants:
`MAX_RETRIES=4`, `BACKOFF_BASE_MS=1000`, transient 5xx set `{500,502,503,504}`.

| Export                     | Endpoint (GET)                                             |
| -------------------------- | ---------------------------------------------------------- |
| `listAffiliates`           | `/affiliate-manager/{locationId}/affiliates`               |
| `getAffiliate`             | `/affiliate-manager/{locationId}/affiliates/{affiliateId}` |
| `listAffiliateCommissions` | `/affiliate-manager/{locationId}/commissions`              |
| `listAffiliatePayouts`     | `/affiliate-manager/{locationId}/payouts`                  |

Error handling: `429` honors `Retry-After` (else exponential backoff); `401/403`
returns `{ success: false, unauthorized: true }` so the sync can classify
"scope not granted yet" distinctly; 5xx and network rejections retry; the helper
never throws. `extractListItems` defensively unwraps the common GHL list shapes.
Pagination is `skip` + `limit`; the page loop lives in the sync, not here. There
are **no campaign endpoints** (none exist upstream).

### Sync action — [`convex/prospecting/affiliateSync.ts`](../../convex/prospecting/affiliateSync.ts)

`runAffiliateSync` is an `internalAction` (`{ locationId?, actor?, dryRun? }`).
An explicit `locationId` requires a verified actor authorized for that location;
scheduled calls omit both fields and use the deployment allow-list. Constants:
`SYNC_PAGE_LIMIT=100`, `MAX_AFFILIATE_SYNC_PAGES=25`, `UPSERT_BATCH_SIZE=100`.

Flow:

1. Read `GHL_AFFILIATE_SYNC_LOCATION_IDS`; if empty → no-op, `{ enabled: false }`.
2. Per location: `getFreshGhlAccessToken` → page affiliates, commissions, payouts.
3. On `401/403`, fall back to an **agency location-token exchange**
   (`POST /oauth/locationToken`, `Version` header `2021-07-28`) and retry.
4. Build a campaign name→id map (from a DB seed query + embedded refs), map rows,
   **derive** `ghlAffiliateCampaigns`, then batch-upsert via `internalMutation`s
   (`upsertSyncedAffiliates/Campaigns/Commissions/Payouts`) — skipped under `dryRun`.

Idempotency: every upsert keys on `(locationId, ghl*Id)` and patches the full row
on match. `upsertSyncedCampaigns` deliberately **strips `enrollmentWorkflowId`**
from the patch so the sync can never clobber the staff-set override.

Cron: `crons.interval("ghl affiliate manager sync", { hours: 1 }, …)`
([`convex/crons.ts`](../../convex/crons.ts) L104-L118).

***

## Phase 2 — Partner ↔ lead/client linking (FUND-2040)

### Query-time resolution, no link table

[`convex/prospecting/affiliateLinking.ts`](../../convex/prospecting/affiliateLinking.ts)
resolves "who referred this contact" at read time from the cache — there is **no
derived link table** (plan decision D2). The referral edge is
`commission.contactId → commission.ghlAffiliateId`. Consequences:

* A referred contact with **no commission row yet is invisible** to the join (this
  limitation is surfaced in the admin UI copy).
* `resolvePartnerForAffiliateContact` (pure, unit-tested) picks the affiliate
  owning the **earliest** commission (ISO-string compare, deterministic tiebreak on
  `ghlCommissionId`) as the winner; multiple claimants set `ambiguous: true` and
  list `conflictingAffiliateIds`.
* A **self edge** (a contact that *is* an affiliate's own `contactId`) is tracked
  separately from the referral edge — a contact can be both.
* Campaign ids are canonicalized (name→id) so rows written before the sync fix
  still resolve.

Scan caps keep reads bounded: `COMMISSION_SCAN_CAP=500`,
`LINKED_CONTACT_ENRICH_CAP=100`, `CONTACT_COMMISSION_CAP=50`,
`SELF_CANDIDATE_CAP=10`, `CAMPAIGN_SCAN_CAP=100`.

Data surface (all `internalQuery`, location-gated, return `null` for unauthorized actors):

| Function                    | Purpose                                                                                 |
| --------------------------- | --------------------------------------------------------------------------------------- |
| `listAffiliatePartners`     | Paginated partner list with rollups (linked leads, commission total, last activity)     |
| `getAffiliatePartnerDetail` | One partner + linked contacts, enriched from `contactPipelineStates` and `fundingPlans` |
| `getReferralForContact`     | Reverse lookup — which partner referred a given CRM contact                             |

### Operator and admin surfaces

* Shared API handler:
  [`app/api/admin/affiliate-partners/route.ts`](../../app/api/admin/affiliate-partners/route.ts),
  re-exported at [`app/api/affiliate-partners/route.ts`](../../app/api/affiliate-partners/route.ts)
  for the operator surface. It dispatches `syncLocation`, `listPartners`,
  `partnerDetail`, `referralForContact`, and the Phase 3 operations; pagination
  is capped at 100. Normal operators are pinned to their session location.
* Operator UI: [`app/(main)/partners/page.tsx`](../../app/\(main\)/partners/page.tsx)
  and [`app/(main)/partners/[affiliateId]/page.tsx`](../../app/\(main\)/partners/\[affiliateId]/page.tsx).
* Admin UI: [`app/admin/partners/page.tsx`](../../app/admin/partners/page.tsx) (list) and
  [`app/admin/partners/[affiliateId]/page.tsx`](../../app/admin/partners/\[affiliateId]/page.tsx)
  (detail), both feature-flagged and wired through
  [`app/admin/partners/useAffiliatePartners.ts`](../../app/admin/partners/useAffiliatePartners.ts).

### One-off migration

`fund2040DedupAffiliateCampaignNames`
([`convex/oneOffMigrations.ts`](../../convex/oneOffMigrations.ts), \~L3470) removes
name-keyed duplicate `ghlAffiliateCampaigns` rows, canonicalizes `ghlCampaignId` on
commissions/affiliates, and preserves `enrollmentWorkflowId` onto the canonical row.
Run it per-location, dry-run first:

```bash theme={null}
bunx convex run --prod oneOffMigrations:fund2040DedupAffiliateCampaignNames \
  '{"locationId":"<locationId>","dryRun":true}'
```

***

## Phase 3 — Workflow enrollment bridge (FUND-2041)

Because Affiliate Manager has no enroll API, staff pre-builds one GHL **Workflow**
per campaign that performs the enrollment when a contact is added to it. The
module's job is to add the contact to the *right* workflow.

Resolution order (**locked**, [`convex/lib/ghlWorkflowLookup.ts`](../../convex/lib/ghlWorkflowLookup.ts)):

1. The campaign's staff-set `enrollmentWorkflowId` override, if present.
2. Name-convention match against `GET /workflows/`: a **published** workflow named
   `Partner Enroll — {Campaign Name}` (note the **em-dash** `—`, constant
   `PARTNER_ENROLL_WORKFLOW_PREFIX`).
3. A clear staff-facing error — never a silent failure. Error strings say "CRM",
   never GHL (Locked Decision).

The workflows list is SDK-backed (`client.workflows.getWorkflow`) and cached in
module memory per location (`WORKFLOW_CACHE_TTL_MS = 10 min`).

| Function                                                  | Type               | Purpose                                                                                                                                                          |
| --------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listCampaignsForEnrollment` / `getCampaignForEnrollment` | `internalQuery`    | Campaign lookup for the enrollment path ([`affiliateCampaignAdmin.ts`](../../convex/prospecting/affiliateCampaignAdmin.ts)); staff/bridge only, no browser route |
| `setEnrollmentWorkflowOverride`                           | `internalMutation` | The **only** writer of `ghlAffiliateCampaigns.enrollmentWorkflowId`                                                                                              |
| `enrollContactInCampaign`                                 | `internalAction`   | Resolve workflow → `addContactToWorkflowViaSdk` ([`enrollAffiliate.ts`](../../convex/prospecting/enrollAffiliate.ts))                                            |

Enrollment is asynchronous: the CRM workflow creates the affiliate downstream, and
the next hourly sync pulls it into the cache. `enrollContactInCampaign` returns
`{ success, workflowSource: "override" | "name-match", … }`.

### Staff-only path (no operator UI)

The campaign auto-enrolls its own affiliates, so the operator-facing enroll card
and workflow-override field are gone, and so are the `enroll`,
`setEnrollmentWorkflow`, and `listCampaigns` operations on
[`app/api/admin/affiliate-partners/route.ts`](../../app/api/admin/affiliate-partners/route.ts)
— nothing a browser can reach calls these functions. The Convex functions
themselves stay: `enrollContactInCampaign` is the backend of
[`POST /api/marketplace/partner-enrollment`](../../app/api/marketplace/partner-enrollment/route.ts),
and `setEnrollmentWorkflowOverride` is the staff emergency path. Run either from
the Convex dashboard or the CLI:

```bash theme={null}
# Point a campaign at a specific enrollment workflow (overrides name matching)
bunx convex run prospecting/affiliateCampaignAdmin:setEnrollmentWorkflowOverride \
  '{"actor":{"userId":"<staff-user-id>","email":"<staff@…>","role":"admin"},
    "locationId":"<locationId>","ghlCampaignId":"<campaignId>",
    "enrollmentWorkflowId":"<workflowId>"}'

# Enroll one contact by hand (the normal path is the CRM campaign itself)
bunx convex run prospecting/enrollAffiliate:enrollContactInCampaign \
  '{"actor":{"userId":"<staff-user-id>","email":"<staff@…>","role":"admin"},
    "locationId":"<locationId>","contactId":"<contactId>",
    "ghlCampaignId":"<campaignId>"}'
```

Both are admin-gated internally (`isAdminActor`), so a non-admin actor returns
`null` no matter where the call comes from. Omitting `enrollmentWorkflowId`
clears the override and restores name matching.

***

## Phase 4 — PDF partner report + scheduled email (FUND-2042)

Reuses the analytics reporting engine with a new `dataSource: "partner"`.

### Report data — [`convex/analytics/partnerReport.ts`](../../convex/analytics/partnerReport.ts)

`run` (`internalAction`) requires a `ghlAffiliateId equals <id>` scope filter
(`PARTNER_SCOPE_FILTER_FIELD = "ghlAffiliateId"`). `_scan` fetches the partner's
commissions + payouts and enriches referred contacts. Output columns are
`PARTNER_COLUMNS` ([`convex/analytics/columns.ts`](../../convex/analytics/columns.ts))
— a `rowType` of `lead` or `payout` plus contact/commission/payout fields.
`dataSource: "partner"` is excluded from the operator ReportBuilder via
`OPERATOR_DATA_SOURCES` ([`convex/analytics/types.ts`](../../convex/analytics/types.ts)).

### Scheduling — [`convex/analytics/partnerReporting.ts`](../../convex/analytics/partnerReporting.ts)

Two send paths, resolved at send time (**locked**): a per-partner **override
schedule** wins; otherwise the **global default** sends *if enabled*; otherwise
skip.

* `partnerReportingSettings` is a **singleton** ([`convex/schemas/analytics.ts`](../../convex/schemas/analytics.ts)),
  `enabled` defaults `false`. It holds the global default cadence/format and the
  cron bookkeeping (`nextRunAt`, `lastSentAt`, `lastError`) for the shared default
  window. Delivery-time fallbacks: `9:00 UTC`, Monday, the 1st.
* Per-partner overrides live on `analyticsScheduledReports` with `partnerId`
  (a `ghlAffiliateId`) and `outputFormat` (`csv` | `pdf`), indexed `by_partner`.
* `getSettings`/`updateSettings`/`getPartnerSchedule`/`upsertPartnerSchedule`/
  `removePartnerSchedule` back the admin API
  ([`app/api/admin/partner-reporting/route.ts`](../../app/api/admin/partner-reporting/route.ts))
  and cards `PartnerReportingSettingsCard.tsx` / `PartnerReportScheduleCard.tsx`.
  `MAX_RECIPIENTS=20`.

### Delivery — [`convex/analytics/scheduleCron.ts`](../../convex/analytics/scheduleCron.ts) + [`emailReportNode.ts`](../../convex/analytics/emailReportNode.ts)

The existing `"analytics scheduled report send"` cron (`{ minutes: 15 }`,
`sendDueReports`) is extended: after normal due schedules, it claims the global
default window with a persisted cursor and lease, dispatches one bounded page per
action invocation, and advances `nextRunAt` only after the final page. A failed page
records the error, releases its lease, and leaves the window due for safe resumption.
When `outputFormat === "pdf"`, `emailReportNode` renders a real PDF via
[`convex/analytics/pdfRenderer.ts`](../../convex/analytics/pdfRenderer.ts)
(`renderPartnerReportPdf`, jsPDF, brand navy/gold) and attaches it to the CRM email.
`sendPartnerDefaultOne` handles affiliates with no saved override row.

Enrollment execution IDs are durable and payload-bound. The bridge records
`sideEffectStartedAt` immediately before adding the contact to the workflow. If
the external outcome is ambiguous or completion cannot be recorded, later calls
fail safe with an unknown-outcome response and never replay the workflow add;
leases remain reclaimable only when a crash happened before side-effect start.

***

## Enabling admin and sync

Enable scheduled sync before revealing the launch-flag-gated operator/admin surfaces:

1. **Grant scope.** Ensure each location's install has
   `affiliate-manager.readonly`. A sync `401/403` means the scope is not granted.
2. **Enable the scheduled sync.** Set `GHL_AFFILIATE_SYNC_ENABLED=true` on the
   Convex deployment. Installed-location discovery is primary;
   `GHL_AFFILIATE_SYNC_LOCATION_IDS` is additive emergency coverage. A signed-in
   operator or admin can also use **Refresh**
   on `/partners`; explicit-location refreshes require the verified session actor
   and do not trust a bare `locationId`.
3. **Reveal the UI.** Set `NEXT_PUBLIC_PARTNER_MODULE_ENABLED=true` and **redeploy**
   Next.js (build-time inline).
4. **(Optional) Enrollment.** For each campaign, either publish a workflow named
   `Partner Enroll — {Campaign Name}` or set an `enrollmentWorkflowId` override via
   the admin UI.
5. **(Optional) Reporting.** Turn on `partnerReportingSettings` (admin UI) for
   global sends, and/or add per-partner override schedules.

## Referral assets: freshness and the missing-referral runbook

Referral assets (the sub-affiliate signup link and its embed) can only be built
from the partner's **CRM referral ID**, which the CRM exposes solely through the
"Affiliate Enrollment → Save Referral ID & Link" workflow's dynamic values. No
public API returns it, so when that workflow does not run for a partner, this app
has nothing to synthesize from and says so instead of inventing a link.

**How the app gets fresh without asking.** Three cost tiers, all attributed in
the Convex logs by the `trigger` field on `[GHL affiliate sync] run start`:

| Trigger          | What it costs                     | When it fires                                                                                                                     |
| ---------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `cron`           | Full discovery for every location | The hourly sync                                                                                                                   |
| `manual`         | Full discovery, one location      | The icon-only refresh on `/partners` (troubleshooting)                                                                            |
| `auto-discovery` | Full discovery, one location      | Opening a partner whose detail is stale (>15 min) or whose assets are unresolved — client-coalesced and debounced to once per 60s |
| `auto-focus`     | Full discovery, one location      | Returning to the tab after visiting the CRM through an "Add partner" / "Open in CRM" link, same 60s debounce                      |
| `targeted-poll`  | ≤2 CRM calls, one affiliate       | `refreshAffiliateReferralIdentity`, the poll behind the card's waiting state (0/20/45/90/150s, then it stops)                     |

The card's states come from [`_lib/referralAssetsCardState.ts`](../../app/\(main\)/partners/_lib/referralAssetsCardState.ts):
pending (poll in flight — the only state that shows a spinner), waiting (nothing
running, so it says the assets aren't ready and offers "Check again"), exhausted
(budget spent — "Check again" plus, for admins only, the manual link override), or
ambiguous (the partner is in more than one campaign, so nothing is attributed and
no poll runs).

**Diagnosing a partner whose assets never arrive** (read-only, in this order):

1. Confirm the affiliate exists in the campaign but `contact.affiliate_referral_link`
   / `affiliate_referral_id` are blank. That combination means the read side is
   fine and the workflow is the problem.
2. Open the workflow's Enrollment History for that contact (CRM UI, or
   `ghl_get_workflow_executions` via the GHL MCP server). No execution recorded is
   the usual answer.
3. Check the trigger configuration against the path the affiliate was actually
   created through. Known causes, most common first: the affiliate was added by
   hand in the Affiliate Manager UI and the trigger only covers the form path; the
   trigger's campaign filter does not match the campaign; the workflow was
   published after the affiliate enrolled.

**Repairing it.** The repair is upstream — make the workflow fire — and it is
staged deliberately:

1. Test with a **synthetic** affiliate/contact whether adding a contact directly
   to the workflow resolves the dynamic referral values outside the enrollment
   trigger. If it does, that is the cheap fix.
2. Anything touching a **real** partner's record (including remove/re-add
   re-enrollment) needs a snapshot of the affiliate record and its commission
   history, a written impact check, and Brock's explicit approval first —
   re-enrollment can reset or duplicate attribution.
3. Last resort: an admin pastes the referral link into the card's manual
   override, which is exposed only in the exhausted state.

Fixing the trigger itself is a configuration change in the shared admin-account
workflow and also needs Brock's go-ahead.

## Troubleshooting

| Symptom                                                                     | Likely cause                                                                                                                                                                                                         |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sync returns `{ enabled: false }`                                           | `GHL_AFFILIATE_SYNC_ENABLED` is not `"true"`                                                                                                                                                                         |
| Sync logs `unauthorized` skip                                               | Install lacks `affiliate-manager.readonly`; token-exchange fallback also failed                                                                                                                                      |
| `/admin/partners` or `/partners` 404s                                       | `NEXT_PUBLIC_PARTNER_MODULE_ENABLED` not `"true"` (or not redeployed)                                                                                                                                                |
| Partner shows no linked leads                                               | Referred contacts have no commission rows yet (query-time join limitation)                                                                                                                                           |
| Duplicate campaign rows                                                     | Run `fund2040DedupAffiliateCampaignNames` (dry-run first)                                                                                                                                                            |
| Enrollment errors "no workflow"                                             | No `Partner Enroll — {Campaign}` workflow (published) and no `enrollmentWorkflowId` override                                                                                                                         |
| No partner emails sent                                                      | `partnerReportingSettings.enabled` is `false`, or check `lastError`                                                                                                                                                  |
| Campaign CRUD via API fails (404)                                           | Expected — Affiliate Manager API is read-only; create campaigns in the CRM UI or request the queued automation (below)                                                                                               |
| Campaign automation button disabled                                         | The Apply for Funding funnel has no connected domain, a request is already in flight, or the campaign exists — the Funding Partners campaign section of Setup & health states which                                  |
| No campaign button on `/partners`                                           | Expected when the server's decision is not actionable: the escalation banner only carries the button when `action.enabled`, and disappears entirely once the campaign exists. Open Setup & health for the full state |
| Readiness, enrollment setup, signup assets, or reporting defaults "missing" | They moved into the collapsed "Setup & health" disclosure at the top of `/partners`; reporting defaults stay admin-only                                                                                              |
| Campaign request stuck on "Queued"                                          | The off-platform worker is not running or its token does not match; see [partner-campaign-automation.md](../internal/partner-campaign-automation.md)                                                                 |
| Referral assets stuck on "Finishing partner setup"                          | The CRM referral workflow has not written `affiliate_referral_id` for that partner — see the runbook above                                                                                                           |
| Card says the partner is in more than one campaign                          | Two campaign memberships both resolve a signup form; pin this account's enrollment form in Setup & health                                                                                                            |
| No enroll button / workflow-override field in the UI                        | Removed on purpose — the campaign enrolls its own affiliates. Staff run both via `bunx convex run` (Phase 3 above)                                                                                                   |
| Nothing refreshed after editing a partner in the CRM                        | The return-from-CRM refresh only arms on this app's own "Add partner" / "Open in CRM" links, and debounces to once per 60s                                                                                           |

## Gotchas / invariants

* **Cache shapes are live-verified (2026-07-12, `oE9ILHco0XXZUu79wAA0`).** `amount`
  units are whole USD dollars (not cents). `rawData` remains the source of truth
  for any field the normalizer does not project. Shapes on *other* locations
  should still be spot-checked before trusting their cache.
* The sync **never writes `enrollmentWorkflowId`** — only `setEnrollmentWorkflowOverride` does.
* `partnerId` on schedules is always a **`ghlAffiliateId`**, never a `prospectingPartners._id`.
* The em-dash in `Partner Enroll — {Campaign}` is load-bearing (matched literally).
* Enrollment is eventually consistent: the affiliate appears only after the CRM
  workflow runs and the next sync pulls it in.

## Why PR #1375's gates missed the operator plumbing and the endless spinner

The July 2026 hotfix (operator-only campaign card, finite create progress,
terminating referral-assets poll, worker preflight) fixed symptoms PR #1375's
test suite was green on. Three gaps, kept here so the next page rework doesn't
repeat them:

* **Source-string gates, not render gates.** The page tests
  (`tests/partnersCampaignAutomationPlacement.test.ts`) asserted what
  `page.tsx` *mentions* — component names and prop strings — which proves
  wiring, not visibility. The Setup & health disclosure rendered for every
  viewer, and no test rendered the page as a non-admin to notice.
  `tests/partnersOperatorSurface.test.ts` now renders both roles.
* **Only the happy poll path terminated.** The polling tests walked the
  attempt ladder to its terminal states, but every early-return path (no
  locationId, failed first read, cached "needs polling" contradicted by the
  fresh read) left `pendingAssets` true forever, and the attempt ceiling
  bounded requests, not time — a stalled request never failed an attempt.
  `tests/partnerReferralAssetsTermination.test.ts` asserts every exit path
  settles and a wall-clock budget exists.
* **The worker had no startup contract to test.** Unit tests stubbed the MCP
  client, so a child env missing `GHL_USER_ID` (or holding a 403 key) was
  unreachable by any test; the failure surfaced one claimed request at a
  time as a redacted generic error. The preflight
  (`runWorkerPreflight`, `tests/partnerCampaignWorkerPreflight.test.ts`) makes
  the contract explicit and refuses to start on it.

## File index

| Area                                      | File                                                                                                                                                                                                                                                                                                                                                        |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Feature flag                              | [`lib/partnerModuleFlag.ts`](../../lib/partnerModuleFlag.ts)                                                                                                                                                                                                                                                                                                |
| P1 API client                             | [`convex/lib/ghlAffiliateApi.ts`](../../convex/lib/ghlAffiliateApi.ts)                                                                                                                                                                                                                                                                                      |
| P1 sync + cron                            | [`convex/prospecting/affiliateSync.ts`](../../convex/prospecting/affiliateSync.ts), [`convex/crons.ts`](../../convex/crons.ts)                                                                                                                                                                                                                              |
| P1 cache schema                           | [`convex/schemas/commissions.ts`](../../convex/schemas/commissions.ts)                                                                                                                                                                                                                                                                                      |
| P2 linking                                | [`convex/prospecting/affiliateLinking.ts`](../../convex/prospecting/affiliateLinking.ts)                                                                                                                                                                                                                                                                    |
| P2 admin API/UI                           | [`app/api/admin/affiliate-partners/route.ts`](../../app/api/admin/affiliate-partners/route.ts), [`app/admin/partners/`](../../app/admin/partners/)                                                                                                                                                                                                          |
| P2 migration                              | [`convex/oneOffMigrations.ts`](../../convex/oneOffMigrations.ts) (`fund2040DedupAffiliateCampaignNames`)                                                                                                                                                                                                                                                    |
| P3 workflow lookup                        | [`convex/lib/ghlWorkflowLookup.ts`](../../convex/lib/ghlWorkflowLookup.ts)                                                                                                                                                                                                                                                                                  |
| P3 campaign admin + enroll                | [`convex/prospecting/affiliateCampaignAdmin.ts`](../../convex/prospecting/affiliateCampaignAdmin.ts), [`convex/prospecting/enrollAffiliate.ts`](../../convex/prospecting/enrollAffiliate.ts)                                                                                                                                                                |
| P4 report + PDF                           | [`convex/analytics/partnerReport.ts`](../../convex/analytics/partnerReport.ts), [`convex/analytics/pdfRenderer.ts`](../../convex/analytics/pdfRenderer.ts)                                                                                                                                                                                                  |
| P4 scheduling + email                     | [`convex/analytics/partnerReporting.ts`](../../convex/analytics/partnerReporting.ts), [`convex/analytics/scheduleCron.ts`](../../convex/analytics/scheduleCron.ts), [`convex/analytics/emailReportNode.ts`](../../convex/analytics/emailReportNode.ts)                                                                                                      |
| P4 reporting schema                       | [`convex/schemas/analytics.ts`](../../convex/schemas/analytics.ts) (`partnerReportingSettings`, `analyticsScheduledReports.partnerId`)                                                                                                                                                                                                                      |
| P4 admin API/UI                           | [`app/api/admin/partner-reporting/route.ts`](../../app/api/admin/partner-reporting/route.ts), [`app/admin/partners/PartnerReportingSettingsCard.tsx`](../../app/admin/partners/PartnerReportingSettingsCard.tsx), [`app/admin/partners/[affiliateId]/PartnerReportScheduleCard.tsx`](../../app/admin/partners/\[affiliateId]/PartnerReportScheduleCard.tsx) |
| Campaign automation (domain gate + queue) | [`convex/lib/partnerFunnelDomain.ts`](../../convex/lib/partnerFunnelDomain.ts), [`convex/lib/partnerCampaignRequests.ts`](../../convex/lib/partnerCampaignRequests.ts), [`convex/prospecting/partnerCampaignAutomation.ts`](../../convex/prospecting/partnerCampaignAutomation.ts)                                                                          |
| Campaign automation UI                    | [`app/(main)/partners/_components/CampaignActionBanner.tsx`](../../app/\(main\)/partners/_components/CampaignActionBanner.tsx) (escalation banner), [`app/(main)/partners/_components/CampaignAutomationPanel.tsx`](../../app/\(main\)/partners/_components/CampaignAutomationPanel.tsx) (setup section)                                                    |
| Referral freshness policy                 | [`app/(main)/partners/_lib/refreshPolicy.ts`](../../app/\(main\)/partners/_lib/refreshPolicy.ts) (poll schedule, staleness, debounce), [`app/(main)/partners/_lib/referralAssetsCardState.ts`](../../app/\(main\)/partners/_lib/referralAssetsCardState.ts) (card states)                                                                                   |
| Partners page setup IA                    | [`app/(main)/partners/_components/PartnerSetupSection.tsx`](../../app/\(main\)/partners/_components/PartnerSetupSection.tsx) (the "Setup & health" disclosure), [`app/(main)/partners/_lib/setupSummary.ts`](../../app/\(main\)/partners/_lib/setupSummary.ts) (its summary line)                                                                           |
| Campaign automation worker                | [`app/api/partner-campaign-worker/route.ts`](../../app/api/partner-campaign-worker/route.ts), [`scripts/partner-campaign-worker.ts`](../../scripts/partner-campaign-worker.ts), [`docs/internal/partner-campaign-automation.md`](../internal/partner-campaign-automation.md)                                                                                |
