> ## 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 affiliate manager rebuild

# 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](https://linear.app/funding-machine/issue/FM-2528/partner-module-rebuild-on-highlevel-affiliate-manager-apis-sept-2026).
Predecessor design: [`partner-module.md`](./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)

| Owner | Owns |
| - | - |
| **Affiliate Manager (HL)** | affiliate identity, campaign membership, referral ids/links, attributed customers, commission records, payout-method configuration |
| **FM local** | funding workflow, lending-specific fee/commission calcs, permissions, reporting projections/caches, `partnerReferralEdges` attribution for FM applicants, Everee 1099 rail |

* 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

| Op | Method / path | Verified | Phase 1 | Notes |
| - | - | - | - | - |
| list campaigns | `GET /affiliate-manager/:loc/campaigns` | live 2026-07-30; scope? | ✅ `listAffiliateCampaigns` | Summary rows only (`subAfFormId` null); detail fetch still required. |
| get campaign | `GET /affiliate-manager/:loc/campaigns/:campaignId` | live 2026-07-27; scope? | ✅ `getAffiliateCampaign` | Only source of `commissionV2` / `productBasedCommission` / `referralRealLink` / `subAfLink`. |
| create / update / delete campaign | — | **not documented** | — | Keep the off-platform worker (`partnerCampaignRequests`) or the HL UI. |

### Affiliates and referral tracking

| Op | Method / path | Verified | Phase 1 | Notes |
| - | - | - | - | - |
| list affiliates | `GET /affiliate-manager/:loc/affiliates` | live 2026-07-27; scope✓ `affiliate-manager.readonly` | ✅ `listAffiliates` | `active` is a documented **string** with default `false`; `limit` 0 means server default 10. Shadow compare probes unfiltered / `active=true` / `active=false` counts. |
| get affiliate | `GET …/affiliates/:affiliateId` | docs; scope✓ | ✅ `getAffiliate` | Sept 7 dropped `clickCount`/`currency`/`customer`/`paid`; added `commissionTier`/`companyName`. |
| create affiliate | `POST …/affiliates` | docs; scope? | — | Same email within location **updates** the existing affiliate (dedupe hazard for backfill). |
| update affiliate | `PUT …/affiliates/:affiliateId` | docs; scope? | — | **Full replacement**; omitted fields clear. |
| update status | `PUT …/affiliates/:affiliateId/status` | docs; scope? | — | `active: boolean`; idempotent on repeat. |
| upload tax form | `POST …/affiliates/:affiliateId/tax-form` | docs; scope? | — | multipart PDF ≤ 5 MB, `formType` w8Form/w9Form. |
| validate referral id | `GET /affiliate-manager/affiliate-campaign/:loc/referral-id/validate` | docs; scope? | ✅ `validateReferralId` | `^[A-Za-z0-9_]{3,64}$`, enforced locally before the call. |
| update referral id | `PUT /affiliate-manager/affiliate-campaign/:loc/affiliate/:affiliateId/referral-id` | docs; scope? | — | Returns rebuilt `referralLink`; old-link continuity is a migration concern. |
| update suspension | `PUT /affiliate-manager/affiliate-campaign/:loc/campaign/:campaignId/affiliate/:affiliateId/suspension` | docs; scope? | — | Cancels pending/denied money; reinstate → pending. |
| move campaign | `POST /affiliate-manager/affiliate-campaign/:loc/affiliate/:affiliateId/move-campaign` | docs; scope? | — | `moveMode` 1 vs 2; `sendWelcomeEmail` defaults **true** — disable for bulk. |

### Attributed customers

| Op | Method / path | Verified | Phase 1 | Notes |
| - | - | - | - | - |
| list customers | `GET …/affiliates/:affiliateId/customers` | docs; scope? | ✅ `listAffiliateCustomers` | Row carries AM `_id` **and** CRM `contactId`. |
| create customer lead | `POST …/affiliates/:affiliateId/lead/create` | docs; scope? | — | Needs `email` + `campaignId`; create-or-update. |
| validate customer lead | `GET …/affiliates/:affiliateId/lead/validate` | docs; scope? | ✅ `validateCustomerLead` | `exist: boolean`; not race-free. Not probed by the shadow (needs a real email). |
| update customer state | `PATCH …/affiliates/:affiliateId/customers/:customerId/state` | docs; scope? | — | `isDisabled` only; not a lead→customer conversion. |
| delete customer | `DELETE …/affiliates/:affiliateId/customer/:customerId` | docs; scope? | — | **Singular** `customer` segment. |

### Commissions and payouts

| Op | Method / path | Verified | Phase 1 | Notes | | | |
| - | - | - | - | - | - | - | - |
| list commissions | `GET /affiliate-manager/:loc/commissions` | live 2026-07-12; scope✓ | ✅ `listAffiliateCommissions` | Filter enum \`pending | approved | paid | denied`; `fromDate`/`toDate`= creation date. Documented schema omits`currency`/`customer`; live rows (2026-07-12) nest `customer.{_id,contactId}\` — extractors tolerate both. |
| create commission | `POST /affiliate-manager/:loc/commission` | docs; scope? | — | `amount` ≥ 0 is the **base**; ISO currency required; `eventId` exists but idempotency is **not** documented → local event ledger + readback required before any write. | | | |
| update commission status | `PUT …/commission/:commissionId/status` | docs; scope? | — | `action` pending/approved/paid/denied/**canceled**; also moves the payout. Paid ≠ funds moved. | | | |
| deny / delete commission | `PUT …/commission/:id/deny`, `DELETE …/commission/:id` | docs; scope? | — | Both mutate the associated payout. | | | |
| list payouts | `GET /affiliate-manager/:loc/payouts` | live 2026-07-12; scope✓ | ✅ `listAffiliatePayouts` | Date params are `start`/`end` (differs from commissions). | | | |
| bulk delete / bulk status | staging only | **unverified** | — | Not a dependency. | | | |

### Payout methods, reference data, media

| Op | Method / path | Verified | Phase 1 | Notes |
| - | - | - | - | - |
| list payout methods | `GET /affiliate-manager/:loc/affiliate/:affiliateId/payout-method` | docs; scope? | ✅ `listAffiliatePayoutMethods` | **Singular** `affiliate` and `payout-method`. Bank values masked `********5678`; `parsePayoutMethod` drops any unmasked value and reports only its key. |
| create / update / delete / mark primary | `POST`, `PUT :id`, `DELETE :id`, `PATCH :id/primary` | docs; scope? | — | Type cannot change; cannot delete primary while another remains; never PUT masked values back. |
| list country bank mappings | `GET /affiliate-manager/:loc/country-bank-mapping` | docs; scope? | ✅ `listCountryBankMappings` | Response key undocumented; extractor tries likely keys then generic envelope. |
| list media | `GET /affiliate-manager/:loc/media` | docs; scope? | ✅ `listAffiliateMedia` | No documented media **create/upload**. |
| update / delete media | `PUT :mediaId`, `DELETE :mediaId` | docs; scope? | — | |

### 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

| FM table / concern | Owner | Cutover shape |
| - | - | - |
| `ghlAffiliates` (cache) | HL | Becomes a mirror; identity fields from list/get affiliate. Staff `referralLink` override and sync-written `crmReferralId`/`crmReferralLink` stay FM columns until referral-id APIs are proven. |
| `ghlAffiliateCampaigns` (derived) | HL | Discovery via list campaigns; detail fetch kept for `commissionV2` + referral templates. `enrollmentWorkflowId` stays staff-owned. |
| `ghlAffiliateCommissions` (cache, whole USD) | HL | Mirror. `amount` column = earned `commissionAmount`. Status enum aligned to pending/approved/paid/denied + canceled + unknown. |
| `ghlAffiliatePayouts` (cache) | HL | Mirror; history only — no payout execution API. |
| Payout method UX | HL | Not surfaced today; if surfaced, masked display only. |
| AM attributed customers | HL | Not cached today; read live per affiliate (bounded) or mirrored later. Distinct from `partnerReferralEdges`. |
| `partnerReferralEdges` | **FM** | Unchanged: one referrer per `(locationId, contactId)`, never auto-reassign, unmatched/ambiguous retained. |
| `partnerEnrollmentSettings`, `partnerCampaignSelections`, funnel readiness tables | **FM** | Unchanged. |
| `partnerCampaignRequests` + worker heartbeat | **FM** | Keep — no campaign write API. |
| `partnerEnrollmentRequests` | **FM** | Keep; enrollment may later target POST affiliate / lead create behind a flag. |
| `partnerReportingSettings`, `analyticsScheduledReports` (`partnerId` = `ghlAffiliateId`), send tables | **FM** | Unchanged. |
| Everee tables | **FM** | Orthogonal to AM payout methods. |
| `prospectingPartners`, `partnerOrganizations`/Dub, `salesCommissions`/closer ledgers | **FM, separate keyspaces** | Never joined. |

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

| Piece | Path | Notes |
| - | - | - |
| Read adapter | `convex/lib/ghlAffiliateApi.ts` | Typed opts per endpoint, `limit` clamp 1–100, `active` as string, arrays comma-joined, `meta.count`, 404 → `notFound`, 401/403 → `unauthorized`, 429/5xx/network retry with injectable delay for tests. |
| Typed contracts | `convex/lib/affiliateManagerContracts.ts` | `parseAffiliate`, `parseAffiliateCustomer`, `parseCommission`, `parsePayout`, `parseCampaignCommissionRules`, `parsePayoutMethod`, `normalizeCommissionStatus`. Total functions; never throw. |
| Shadow report (pure) | `convex/lib/affiliateShadowReport.ts` | Presence, field diffs, status histograms, totals by currency+status, attribution rows. Id lists capped at 50 with `truncated`. |
| Shadow compare (Convex) | `convex/prospecting/affiliateShadowCompare.ts` | `runAffiliateShadowCompare` (internalAction, read-only, actor-gated) + `getCachedPartnerSnapshot` (bounded internalQuery). |
| Route op | `POST /api/admin/affiliate-partners` `operation: "shadowCompare"` | Admin-only; args `{ locationId, affiliateSampleSize? }`. |
| Tests | `tests/ghlAffiliateApi.test.ts`, `tests/affiliateManagerContracts.test.ts`, `tests/affiliateShadowCompare.test.ts`, additions to `tests/affiliatePartnersRoute.test.ts` | Contract tests against documented envelopes; no live calls. |

### Running the shadow compare (staff)

```text theme={null}
POST /api/affiliate-partners
{ "operation": "shadowCompare", "args": { "locationId": "<loc>", "affiliateSampleSize": 10 } }
```

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.