Skip to main content

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: Everything in the Partner Module keys on ghlAffiliateId. analyticsScheduledReports.partnerId stores a ghlAffiliateId, not a prospectingPartners._id (convex/prospecting/affiliateLinking.ts L8-L10).

Architecture

Feature flag and kill switch

Two independent switches, on purpose:
  • NEXT_PUBLIC_PARTNER_MODULE_ENABLED (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:
canAccessPartnerModuleLocation (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. 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.

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

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}. 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

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 internalMutations (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 L104-L118).

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

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):

Operator and admin surfaces

One-off migration

fund2040DedupAffiliateCampaignNames (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:

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):
  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). 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 — nothing a browser can reach calls these functions. The Convex functions themselves stay: enrollContactInCampaign is the backend of POST /api/marketplace/partner-enrollment, and setEnrollmentWorkflowOverride is the staff emergency path. Run either from the Convex dashboard or the CLI:
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

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

Scheduling — 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), 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) and cards PartnerReportingSettingsCard.tsx / PartnerReportScheduleCard.tsx. MAX_RECIPIENTS=20.

Delivery — convex/analytics/scheduleCron.ts + 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 (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: The card’s states come from _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

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