Skip to main content

Partner Module rebuild on Affiliate Manager v3 — inventory, gap report, Phase 1 adapter (FM-2528)

Status: Phase 1 shipped for review (read-only adapter, typed contracts, shadow compare, capability detection). No write path, no migration, no cutover. Prod release and any Affiliate Manager write require a separate go-ahead naming the operation and target. Internal engineering note; not part of the Mintlify docs site (docs/docs.json). User-facing copy says CRM; this note says GHL / Affiliate Manager (AM) because it describes the vendor API. Linear: FM-2528. Predecessor design: partner-module.md (FUND-2039 → 2046, pre–Sept-7 assumptions). Sibling: FM-2527 (CSV durable export) is a separate track and not a dependency here.

1. Architecture locks (do not silently change)

  • Durable mapping: FM partner context ↔ locationId ↔ ghlAffiliateId ↔ campaignId ↔ AM customerId ↔ CRM contactId. AM customer _id ≠ CRM contactId; never derive one from the other (parseAffiliateCustomer, parseCommission carry both and never fall back).
  • One writer per entity during rollout. No second commission engine on the same funding event.
  • Affiliate PUT is full replacement (fetch → map writable fields → merge → PUT). Not implemented in Phase 1.
  • Read commissionV2 / leadCommissionV2; productBasedCommission inside a tier is authoritative for product rules; the legacy flat pair is display fallback (parseCampaignCommissionRules).
  • Manual create-commission amount is a base, not necessarily the earned commissionAmount.
  • Suspension cancels pending/denied payouts, commissions and transactions; reinstating returns them to pending.
  • No verified payout-execution or campaign-write endpoint exists in the production inventory. Do not invent one.
  • Two partner systems live on different identity keyspaces (ghlAffiliateId vs clerkOrgId; prospectingPartners._id is a third). Never join across them.

2. Production endpoint inventory and verification status

Legend for Verified: live = a 200 observed from an installed FM location token; docs = documented on the production endpoint page, not exercised by FM; scope✓ = the scope is confirmed by the connected CRM account’s operation registry (2026-09-09); scope? = the endpoint page lists no scope and no registry entry exists. Phase 1 marks what the read adapter (convex/lib/ghlAffiliateApi.ts) implements now. All paths are relative to https://services.leadconnectorhq.com with header Version: v3. The v3 introduction mentions a /v3 route prefix; every live-verified read below answered without it, so the adapter does not use it. If an endpoint ever 404s with not_found in the capability probe, test the prefixed path before concluding the route is missing.

Campaigns

Affiliates and referral tracking

Attributed customers

Commissions and payouts

Payout methods, reference data, media

Verification performed this session (2026-09-09)

  • Production doc pages fetched for: v3 introduction, list affiliates, list campaigns, get campaign, list customers, list commissions, list payout methods, list payouts, validate referral id. All list envelopes are { <resource>: [...], meta: { count } } with skip/limit; no scope names on any endpoint page.
  • The connected CRM account’s operation registry exposes exactly four AM operations — list affiliates, get affiliate, list commissions, list payouts — each requiring affiliate-manager.readonly. None of the September 7 additions appear in it. A live envelope probe against the Whigham location was attempted and refused: that connection is bound to a different single location. No live call was made this session.
  • Prior live verifications cited above come from code comments in affiliateSync.ts, ghlAffiliateApi.ts and schema (2026-07-12 / 07-27 / 07-30, location oE9ILHco0XXZUu79wAA0).
Unverified and must be proven on a non-production install before any write design: scope name(s) for every September 7 endpoint; whether the installed marketplace app can request them; account-token type per endpoint (sub-account vs agency); /v3 prefix behaviour; whether eventId on create-commission is idempotent; the exact semantics of active default false on list affiliates; the response key of country-bank-mapping. The shadow compare’s capabilities map answers the reachability half of this per location.

3. Ownership matrix — FM tables today → owner after cutover

4. Gaps found in the current code (actionable for the write phase)

  1. Earned vs base amount conflation in the cache mapper. mapCommissionRow stores commissionAmount ?? amount. The fallback to amount writes the base into the earned column whenever commissionAmount is absent. Documented rows always carry commissionAmount, so this is latent, but the shadow report surfaces it directly (commissions.baseAmountCachedIds). Recommendation: drop the fallback before any cutover so a missing earned amount stays undefined rather than overstating.
  2. Status enum drift. Cache and UI treat any non-paid status as owed. canceled (write-side enum) and unknown strings must not be read as pending money. normalizeCommissionStatus gives the shared enum; affiliateLinking.isPaidCommissionStatus should move onto it in Phase 3.
  3. Client was GET-only with no capability detection. Now: classifyCapability + runAffiliateShadowCompare.capabilities. Missing-access state still needs an operator-facing rendering.
  4. Referral id still comes from a CRM custom field, not the AM API. validateReferralId is implemented; the referral-id PUT is not. Keep the CRM hydrate until the PUT is proven.
  5. active filter semantics unknown. Sync never sends it. Shadow probe reports the three counts.
  6. Sync’s 401 fallback writes a token. retryAffiliateFetchesAfterLocationTokenExchange persists a new location token. The shadow compare deliberately does not reuse it; Phase 2 writes must decide whether a probe is allowed to mutate installation credentials.
  7. No AM webhooks. Poll/reconcile only; creation-date filters are not an updates-since feed.
  8. Whigham-class mismatch (AM leads ≠ FM linked applicants). The report’s attribution rows give per-affiliate AM-only / FM-only contact ids and customerIdCollisions (an FM contactId that is really an AM customer _id).

5. What Phase 1 adds (this PR)

Running the shadow compare (staff)

or bunx convex run prospecting/affiliateShadowCompare:runAffiliateShadowCompare with an admin actor on a non-production deployment. The run makes only GET requests. Read the capabilities map first: any unauthorized / not_found entry is a scope or route gap for that location, and everything below it is comparison, not proof.

6. Remaining phases (unchanged from the plan; nothing here is authorized yet)

  • Phase 2 — writes behind flags on non-prod/labs locations only: affiliate PUT (fetch→merge→PUT), status, referral-id PUT, commission lifecycle with a local event ledger + readback, payout methods (masked-safe), tax form. Keep workflow enrollment as fallback. No Everee cutover, no paid approval.
  • Phase 3 — attribution: prefer AM referral id, CRM field as backup; bounded reconcile with dryRun→write gates; operator UX unchanged.
  • Phase 4 — restartable backfill with stable mappings, duplicate-email handling, checkpoints, reconciliation totals, legacy snapshot, old-link continuity. Pilot one location.
  • Phase 5 — acceptance matrix: full-replacement edits without loss, cross-location isolation, inactive affiliates, pagination > 100, 429/5xx recovery, duplicate event retries, referral collisions, parent/tier, suspension effects, move modes, masked bank, 5 MB PDF, paid-history preservation, commissionV2 rules, Whigham fixtures.
  • Phase 6 — decommission workarounds only where AM provides the capability.