Skip to main content

Partner Portal Expansion: Role Routing, Commission Tracking, and Payouts — Implementation Plan

Status: Proposal for review — no code changes made Author: Claude (research + synthesis), for Brock Date: 2026-07-12 Base commit: 4177add3 (feat(partner-module): harden Marketplace enrollment bridge (#1197)), verified equal to origin/main (0 commits behind, clean tree)
Evidence labels used throughout: [code] = verified in the current repository at the cited path. [live] = verified against live/production data (only via the FUND-2043 live verification of 2026-07-12; see §22). [everee] = verified in official Everee documentation (URL cited). [inferred] = reasonable conclusion, not directly verified. [unknown] = requires confirmation before implementation.

1. Executive summary

The Partner Portal today is a live but thin Clerk-authenticated referral surface whose earnings page reads a commission ledger that nothing writes to. The repo contains three disjoint “partner” systems, no GHL user directory, no RBAC, no payout execution of any kind, and zero Everee integration. The applicant→client “conversion” is not a code path at all — it is a human moving/creating GHL opportunities — so nothing today can preserve role assignments across conversion except anchoring them on the one durable key both records share: the GHL contactId. This plan proposes:
  1. A synced GHL user directory (ghlUsers) plus a payee registry (payees) that unifies partners, employees, contractors, and GHL users as commission recipients.
  2. Contact-anchored role assignments (roleAssignments) with append-only history, so assignments survive applicant→client conversion by construction rather than by copying.
  3. A small, explicit-priority, first-match routing-rules engine (“if partner X refers, assign funding assistant Y…”) evaluated idempotently from the existing lead-intake webhook path, with per-evaluation audit records that answer “why was this person assigned.”
  4. Option C — a canonical internal commission ledger (commissionPlans + commissionEntries, integer cents, append-only status events, calculation snapshots, one shared idempotency namespace) with the GHL Commission/Affiliate Manager kept as a read-only attribution and reporting input and Everee as the payout execution rail behind an adapter. Option A (build on GHL’s Affiliate Manager) is not viable: its public API is read-only for affiliates/commissions/payouts, campaign CRUD returns 404, the OAuth token does not even carry the affiliate scope today, and it has no employee/contractor concept [code].
  5. An Everee integration modeled on the repo’s strongest existing reliability pattern (billingRetryQueue: lease claims, exponential backoff, audit_failed reconciliation state) — verified against official Everee docs: company-tenant-scoped API tokens, hosted worker onboarding (SSN/bank data never touches our servers), Payables API with externalId upsert idempotency, HMAC-SHA256 signed webhooks, and an admin-approval gate before money moves [everee].
  6. Ten small phases, each independently PR-able, with foundational work (security fix, user sync, roles) separated from commission math, payout writes, and production enablement. Nearly every phase touches VISION.md Out-of-Scope areas (schema, billing math, auth, payments), so this document is the approval vehicle: per-phase approval requirements are called out explicitly.
Blocking pre-requisite: the known cross-tenant P0 in partnerOrganizations.create (convex/partnerOrganizations.ts:197-257) lets any authenticated portal org bind an arbitrary locationId and read that tenant’s commission data [code]. It must be fixed (Phase 0) before any commission or payout expansion makes the exposed data more valuable.

2. Current-state findings

2.1 Three disjoint “partner” systems [code]

There is no code path linking any two of these keyspaces (docs/design/partner-module.md explicitly warns against crossing them) [code].

2.2 Partner Portal (System A) specifics [code]

  • Pages: dashboard, getting-started (4-step onboarding), referrals (Dub.co short links, ?ref=<clerkOrgId> appended — app/api/partner-portal/referral-links/route.ts:36), earnings, branding, team (pure Clerk org APIs), settings (thin), pricing (delegates to Clerk <PricingTable>).
  • Earnings are derived at query time from salesCommissions filtered by the org’s bound locationId (convex/partnerEarnings.ts:32-102). There is no partnerEarnings table.
  • Cross-tenant P0: partnerOrganizations.create (convex/partnerOrganizations.ts:197-257) inserts caller-supplied locationId with no ownership verification; onboarding validates only ^[a-zA-Z0-9]{10,}$ client-side (app/partner-portal/getting-started/page.tsx:148). No uniqueness guard on locationId either. Any partner can read another tenant’s commission/earnings aggregates.
  • checkSlugAvailability (convex/partnerOrganizations.ts:186) has no auth (slug enumeration); resolvePartnerName (:34) is dead code; the organizations table (convex/schemas/core.ts:84) is vestigial.
  • No portal sunset flag or FUND-1847 marker exists anywhere in the repo — the prior “portal sunset” understanding is not reflected on main [code]; treat the portal as the surface being expanded, per this task.

2.3 Commission reality: the ledger is vestigial [code]

  • salesCommissions (convex/schemas/commissions.ts:317-351) and closerCommissions (:354-381) have writers (recordCommission at convex/salesCommissions.ts:4-70, recordCloserCommission at convex/closerCommissions.ts:314, plus markCommissionsAsPaid/markCloserCommissionsPaid) with zero in-repo callers (grep-verified) — but until PR #1206 all four were public, unauthenticated mutations: anyone reaching the deployment could forge or mark-paid rows for any locationId (the key portal earnings read by), and grep cannot rule out external callers of a public API (their Zoho-shaped args point at the Zoho webhook removed in #63). PR #1206 converted them to internalMutation. The portal earnings page therefore renders empty/legacy data.
  • The live commission surface is app/api/admin-tracking/analytics/route.ts, which recomputes setter commissions per request from GHL calendar events + orders + setterCommissionSettings, and persists nothing.
  • Commission settings tables are active and are good precedent for “commission plans”: setterCommissionSettings (flat vs percentage, per-setter per-location), locationCommissionDefaults, closerCommissionSettings (convex/schemas/commissions.ts:14-82).
  • Lifecycle today is a boolean isPaid — no pending/approved/payable states, no clawbacks, no adjustments, no audit history.
  • The manual process is documented in docs/SOP-Paying-Commissions.md (manual payroll via Gusto; 20showed/20 showed / 50 sold; Wed–Tue pay period) [code].
  • docs/design/fund-1921-subpartner-commissions.md is the closest existing design (multi-level partner override chains; design-only, not implemented). Its idempotency lesson — “delete-unpaid-then-upsert, never touch paid rows” — is carried into this plan.

2.4 GHL integration surface [code]

  • No ghlUsers table. Users are fetched read-through only: getGHLUsersByLocation (lib/ghl-sdk.ts:1622), convex/lib/ghlApi/users.ts:61, and app/api/ghl/users/route.ts (mentions picker). The only assignable-user registry is the hand-curated processingTeam table (convex/schemas/agencyOps.ts:449-458).
  • Scopes diverge by authorize route: app/api/ghl/authorize/route.ts:25 includes users.readonly/users.write and payments scopes; app/api/lb/authorize/route.ts:19 (the marketplace flow) does not. affiliate-manager.readonly is absent from both — every affiliate sync file treats 401/403 as an expected skip. (But see §22: FUND-2043 live verification found the scope granted on the admin location oE9ILHco0XXZUu79wAA0 [live].)
  • Routing substrate exists but is dormant: roundRobinState (convex/schemas/core.ts:45-50), leadReassignmentAudits (:52-67), and convex/leadReassignment.ts (selectNextAssignee:565, writes opportunity.assignedTo + contact.assignedTo in GHL) — its driving cron was removed 2026-06-30. convex/autoAssign.ts is a live load-balancer for processing submissions over processingTeam.
  • Webhook transport has no dedup/replay protection for GHL events. The webhookEvents table is a GitHub webhook log, not a GHL idempotency store; idempotency is pushed into each handler. Signature verification is robust (ED25519 → RSA → HMAC; lib/utils/verify-webhook-signature.ts).
  • GHL Affiliate Manager public API is GET-only (convex/lib/ghlAffiliateApi.ts wraps only list/get); campaign CRUD 404s (AGENTS.md, Learned Workspace Facts: GHL Affiliate Manager read-only); commissions/payouts cannot be written to GHL.

2.5 Applicant/client model and “conversion” [code]

  • Applicant = GHL opportunity in the Applicants pipeline; client = GHL opportunity in the Clients pipeline. No Convex applicants/clients tables — Convex stores side-car state (contactPipelineStates, underwriting, processing) keyed by contactId + locationId.
  • There is no conversion mutation. A client is either an applicant opp moved to Won - Signed (rendered on /clients as synthetic Agreement Signed, lib/hooks/useClients.ts:390-402) or a new Clients-pipeline opportunity created by a human in GHL. ensureApplicantOpportunityHandler (convex/lib/underwritingGhlSync.ts:274 onward; public wrapper at :221) creates/updates opportunities in the Applicants pipeline but never moves opps between pipelines — the client-materialization detector (§7) must not treat its Applicants-pipeline creations as conversions.
  • Therefore owner/role assignments do not persist across conversion today. The durable cross-record key is contactId.
  • Referral attribution is captured only at intake into leadEvents (source, dubId, referralOrgIdconvex/schemas/agencyOps.ts:398-428) from GHL custom fields (lib/webhooks/contact-create-handler.ts:94-102); it is never propagated onto opportunities or clients.

2.6 Auth, roles, tenancy [code]

  • No RBAC. Authorization = hardcoded super-admin email allowlist (convex/lib/superAdmin.ts:18-32) + admin-location allowlist (:78-88). The GHL SSO session carries userId/email/role/locationId/companyId/type (lib/auth/session.ts:29-37) but role is never enforced.
  • Convex ctx.auth.getUserIdentity() is populated only for Clerk (portal) requests (convex/auth.config.ts); operator/admin apps pass a verified actor object into internal* functions checked by isAdminActor (convex/lib/adminActor.ts:12-26) — the pattern any new admin-gated write must follow (AGENTS.md, Admin / iframe auth pattern).
  • Tenant isolation = locationId arg + by_location index convention, enforced at the HTTP edge by requireSessionWithLocation (lib/auth/require-session.ts:141-183). Agency-type sessions get cross-location access; app/api/mcp-keys/route.ts:33-50 added a stricter rejectIfCrossLocationUnlessAdmin guard — reuse it for anything credential- or money-related.
  • Secrets today: (1) Convex deployment env vars (ENGINE_BEARER_TOKEN, STRIPE_*, DUB_API_KEY); (2) the mcpApiKeys hashed mint-once pattern (convex/schemas/mcp.ts:18-41, convex/mcpApiKeys.ts); (3) OAuth token rows (ghlInstallations, plaintext). No “test connection” UX exists yet.

2.7 Reusable reliability patterns [code]

  • billingRetryQueue (convex/schemas/invoicing.ts:300-332, convex/billingRetryQueue.ts): lease-based claim (15-min), idempotent enqueue state machine, exponential backoff (5/15/45/135 min), exhausted + audit_failed (external call succeeded, local write failed — reconciliation-only state), 10-minute drain cron, Node-action split for SDK calls. The template for Everee dispatch.
  • operatorInvoices idempotent reserve→create→send keyed on sourceClientInvoiceId, immutable feeBasisSnapshot, durable mfmInvoiceSendAttemptedAt pre-send marker (convex/schemas/invoicing.ts:92-201, lib/billing/operatorFeeInvoice.ts:560-697).
  • Documented double-billing pitfall (AGENTS.md, Learned Workspace Facts: double-billing pitfall): two fee emitters with independent idempotency keys can double-bill the same deal. The ledger design below mandates one shared idempotency namespace across all emitters.
  • partnerEnrollmentRequests (convex/schemas/commissions.ts:258-273): durable request ledger with lease + sideEffectStartedAt replay guard — the template for external-side-effect idempotency.
  • Money units are inconsistent: commissions/invoices in dollars, Stripe/prospecting in cents, GHL affiliate amounts unverified-in-schema but dollars per FUND-2043 live check [live]. New tables use integer cents (§15).

2.8 What does not exist (greenfield) [code]

Zero repo references to: Everee, payroll APIs, ACH payout, payout execution, a general audit-log table, RBAC roles, routing-rule settings, GHL user sync, or applicant→client conversion logic. “Payouts” today means read-only display of GHL Affiliate Manager payout records (when the disabled sync is on).

3. Goals and non-goals

Goals

  1. Sync GHL users per location and make them (and non-GHL people) assignable to applicants, clients, and referral leads in defined internal/external roles.
  2. Guarantee role assignments persist across applicant→client conversion.
  3. Let admins define simple routing rules (“partner X refers → assign assistant Y / advisor Z / manager A”) with explainability, audit, and manual override.
  4. Track commissions for partners, funding assistants/advisors/managers, employees, and contractors in a canonical internal ledger with a full status lifecycle, splits, snapshots, effective-dated plans, and clawbacks.
  5. Execute payouts through Everee for employees and contractors (and optionally partners-as-contractors), with idempotency, retries, reconciliation, and clear separation of earned vs paid.
  6. Keep GHL Commission/Affiliate Manager as a read-only attribution/reporting input, not a system of record.
  7. Ship in small, independently reviewable PRs behind flags, with sandbox-first payout enablement.

Non-goals

  • A general-purpose automation/workflow builder (rules UI is deliberately constrained).
  • Replacing GHL as CRM system of record for contacts/opportunities/pipelines.
  • W-2 payroll processing logic (tax math, filings) — Everee owns that [everee].
  • Multi-level sub-partner override chains (FUND-1921) — the ledger is designed so FUND-1921 can layer on later, but it is out of scope here.
  • International payouts (Everee is US-focused; see §12).
  • Migrating Systems B/C identities into one keyspace beyond the explicit link fields on payees.

4. Terminology and entity model

Separate entities vs roles on a shared entity? Hybrid, deliberately: identities stay in their existing systems (GHL users, Clerk orgs, prospecting partners, Everee workers); payees is the hub that links them; role semantics live on roleAssignments, and payout-provider linkage lives on payoutProviderAccounts. This is the smallest model that avoids both (a) N² identity-mapping tables and (b) forcing employees/contractors into the Clerk-org partner model where they don’t belong.
Principles:
  1. contactId is the assignment anchor — assignments survive conversion because both applicant and client opportunities share it (§2.5). GHL opportunity.assignedTo becomes a projection we optionally write, never the source of truth.
  2. One canonical ledger, integer cents, append-only events, one idempotency namespace shared by every emitter (webhook, backfill, manual) — the direct lesson of the operator-fee double-billing pitfall [code].
  3. Read-only external systems stay read-only: GHL Affiliate Manager feeds attribution/reporting; we never attempt writes (the API forbids them anyway) [code].
  4. Money movement is queued, leased, retried, and reconciled using the billingRetryQueue pattern, with an explicit admin approval gate before submission — which composes with Everee’s own portal-approval gate [everee].
  5. Everything ships dark: per-location DB flags for role/routing features (like locationSettings.featureFlags), Convex env kill switches for crons (like GHL_AFFILIATE_SYNC_ENABLED), and a build-time NEXT_PUBLIC_* gate for new UI surfaces (like lib/partnerModuleFlag.ts).

6. GHL user and role assignment design

6.1 Storage: what identifies the assignee?

Decision: assignments reference payeeId (internal), with ghlUserId denormalized when the payee is GHL-linked. Rationale:
  • GHL user IDs alone break for partners (Clerk orgs), contractors without GHL seats, and when GHL users are deleted/moved.
  • Internal users rows alone break for people who never log in (contractors).
  • A roleAssignments record referencing a payees hub gives one stable internal ID while preserving the external join keys (ghlUserId for CRM writes, evereeWorkerId for payouts).

6.2 The questions, answered

6.3 Mirroring into GHL (optional projection)

Writing opportunity.assignedTo/contact.assignedTo keeps the CRM usable for GHL-native workflows — the dormant leadReassignment.ts already implements exactly this write pair (:574-598) [code]. Recommendation: mirror one designated role (default funding_assistant, configurable per location) to GHL assignedTo, flag-gated, since GHL has only one assignee slot. All other roles live only in Convex.

7. Applicant-to-client conversion design

Because conversion is human-driven in GHL (§2.5), the safest design removes conversion from the persistence path entirely:
  1. Contact-anchored assignments (primary mechanism). roleAssignments.subjectId = contactId. The /clients view resolves assignments by contactId exactly as /applicants does. Nothing is copied, so nothing can be lost. No migration of GHL data required.
  2. Client-materialization detector (secondary, for GHL mirroring + commissions). A lightweight check — piggybacking on the existing opportunity fetches and/or the ContactTagUpdate/pipeline webhooks — records a client_materialized event on the evaluation log when a contact first gains a Clients-pipeline opportunity or reaches Won - Signed. This event (a) triggers re-projection of the mirrored role onto the new client opportunity’s assignedTo, and (b) is a commission-eligibility trigger candidate (§9).
  3. Backfill behavior (migration). One-off internal mutation (registered in convex/oneOffMigrations.ts per convention [code]) that, per location and only when the feature flag is on, seeds roleAssignments from current GHL opportunity.assignedTo values (role = the location’s designated mirror role, source: "conversion_backfill"). Read-only dry-run mode first (returns counts, writes nothing).
  4. What we explicitly do not do: intercept or automate the pipeline move itself. Changing how operators convert applicants in GHL is a product/behavior change outside this plan.
Recommended safest data model: #1 + #2 with backfill #3 optional per location. Confidence: high — grounded in the verified absence of any conversion code path and the shared-contactId invariant [code].

8. Dynamic routing and assignment rules design

8.1 Product experience

A constrained “Routing Rules” settings section (not an automation builder): an ordered list of rules, each a single sentence rendered from structured fields —
When a lead is referred by [partner picker] (or source = X / campaign = Y), assign [role → payee] (one or more role→payee pairs).
Plus: a default fallback row pinned at the bottom, enable/disable toggles, drag-to-reorder priority, a dry-run preview panel (“paste a contact ID / pick a recent lead → see which rule would fire and why”), and a per-lead “Why assigned?” popover backed by the evaluation log.

8.2 Semantics

8.3 Conditions and actions (v1 vocabulary)

  • Conditions (AND within a rule): referring partner (payee link: referralOrgId / prospectingPartnerId / ghlAffiliateId), lead source (exact/contains), Dub campaign (partnerReferralLinks.campaignName), tag (from ContactTagUpdate). All optional; empty = match-all (fallback only).
  • Actions: assign [{roleKey, payeeId}] (bounded ≤5 pairs); optional alsoMirrorToGhlAssignedTo: boolean.

8.4 Three example rules and their evaluation

Location L1 rules (priority order):
  1. P10enabled — IF referring partner = Acme Partners THEN assign funding_assistant → Yolanda, funding_advisor → Zach.
  2. P20enabled — IF source contains “prospecting” THEN assign funding_assistant → Aaron (replaceExisting: true).
  3. P99enabled, fallback (no conditions) — assign funding_manager → Brock.
Case A — lead referred by Acme via Dub link: ContactCreate webhook → leadEvents.referralOrgId = acme-clerk-org → attribution resolver maps to payee Acme Partners. Evaluation: P10 matches → assigns Yolanda (assistant) + Zach (advisor); evaluation stops (first-match). Log: {matched: P10 v3, applied: [assistant→Yolanda, advisor→Zach]}. Note the fallback did not run — Case A gets no funding manager; if “always assign a manager” is desired, the manager pair belongs on P10 too (first-match semantics made visible in the preview panel). Case B — prospecting-delivered lead, already manually assigned to assistant Maria: trigger fires with source = "prospecting-fm". P10 fails (no Acme attribution). P20 matches; target role funding_assistant has an active manual assignment → manual-wins guard applies even though replaceExisting: true (manual > rule takeover). Log: {matched: P20 v1, skipped: [assistant→Aaron (manual assignment present)]} — the “Why?” popover shows exactly this. Case C — organic lead, no attribution, webhook delivered twice: first delivery: P10, P20 fail; P99 (fallback) assigns funding_manager → Brock. Second delivery — whenever it arrives; GHL deliveries carry no event ID — computes the same {locationId}:{contactId}:lead_created key → lookup-before-insert hit → pure no-op; the original evaluation row remains the single record and no duplicate row is written.

9. Commission ledger design (canonical internal ledger)

9.1 Plans

commissionPlans — effective-dated, immutable versions (same-table rows grouped by planKey, new row per edit; the active version at earn time is snapshotted into the entry):
  • Scope: locationId, roleKey (which role this plan pays), optional payee-specific override (mirrors the existing setterCommissionSettingslocationCommissionDefaults resolution ladder [code]).
  • Basis types: fixed_cents, percent_bps_of_collected (basis = contactInvoices.amountPaid, the load-bearing “actual collected” field per the June-2026 over-billing lesson [code]), tiered (bounded tier array; per guidelines, bounded arrays only [code]).
  • Eligibility: requiresFulfillment (reuse resolveFeeRowFulfillment / the 7-Figures completion gate semantics [code]), minCollectedCents, trigger event type.
  • Splits: a plan may define splitPolicy distributing one earning event across the subject’s role holders (e.g., advisor 60% / assistant 40%); each split leg is its own entry sharing a splitGroupId.

9.2 Entries

commissionEntries — append-only rows, integer cents:
  • Keys: idempotencyKey = comm:{locationId}:{sourceEventType}:{sourceEventId}:{payeeId}:{roleKey}one namespace for every emitter (webhook, backfill, manual), directly addressing the operator-fee double-billing pitfall [code]. sourceEventId must be a durable domain identifier (the GHL invoice ID for invoice_paid, the contactId for client_materialized, an admin-supplied reference for manual) — never a webhook-delivery artifact or per-delivery row _id, since GHL redeliveries carry no event IDs and would mint new keys.
  • Attribution: subjectContactId, roleAssignmentId (the assignment active at earn time), attributedReferral (leadEvents ref), planKey+planVersion.
  • Calculation snapshot: calcSnapshot object embedding plan config, basis amount, basis source ID, split math — reproducibility without re-reading mutable state (pattern: operatorInvoices.feeBasisSnapshot [code]).
  • Status: pending → approved → payable → paid, plus held, disputed, reversed, canceled. Transitions only via internal mutations that also append a commissionEntryEvents row (actor, reason, from→to). No status is ever skipped silently; paid is set only by payout settlement (§13 flows).
  • Clawbacks/reversals: never mutate a paid entry — insert a compensating negative entry (reversalOfEntryId), mark the original reversed. Unpaid entries may be canceled directly. (FUND-1921’s “never touch paid rows” lesson [code].)
  • Adjustments/manual overrides: manual entries (sourceEventType: "manual") require reason + actor and appear in the same audit stream (pattern: writeOff requiring a reason [code]).
  • Earned vs paid separation: “earned” = entry exists in ≥approved; “paid” = linked payoutItems row whose payout reached paid. Reporting never conflates them.

9.3 Calculation triggers

v1 trigger: InvoicePaid (the existing webhook already drives the 15% operator fee — same event, new consumer) plus manual. client_materialized and prospecting-billing events are candidate triggers behind per-plan config. Duplicate webhook deliveries are absorbed by the idempotency key; replayed syncs likewise.

9.4 Historical + org reporting

Entries indexed by_location_status, by_payee_status, by_location_earnedAt. Portal earnings queries (convex/partnerEarnings.ts) are rewired from vestigial salesCommissions to entries where the payee links to the org’s clerkOrgId — restoring truthful portal numbers. Admin reporting gets a per-location and per-payee ledger view with the earned/approved/payable/paid breakdown. Totals are materialized, never derived at read time: commissionBalances (§15) keeps per-(location, payee) running cents per status, updated transactionally with each entry write; queries read the summary row and paginate raw entries only for drill-down. (The current getEarningsSummary sums a 10k-row scan — silently truncating an “all-time” total — and Convex’s 16,384-documents-per-query limit would make query-time aggregation over an append-only ledger produce wrong numbers, not just slow ones, while re-running the scan for every subscribed dashboard on every write.)

10. Commission Affiliate Manager comparison

Context that decides this section: the GHL (“Commission”) Affiliate Manager public API is read-only for affiliates, commissions, and payouts; campaign CRUD returns 404 (AGENTS.md Learned Workspace Facts: GHL Affiliate Manager read-only, convex/lib/ghlAffiliateApi.ts wraps GET only) [code]; amounts confirmed dollars and contact-join = customer.contactId per FUND-2043 [live]; it has no employee/contractor/W-2 concept [inferred from product scope]. Recommendation: Option C. Option A fails on hard constraints (read-only API, no internal-role concept, missing scope). Option B and C differ only in whether we keep the already-implemented, currently-disabled CAM read sync as an attribution/reporting input — we should: it is built, tested, and gives partner-referral attribution (ghlAffiliateCommissions.by_location_contact, FUND-2040) and side-by-side reconciliation for partners the business already manages in GHL. C = B + flip a switch we already own.

11. Organization and payee model

  • payees (new): the recipient hub. kind: partner | employee | contractor | external, display fields, status (active/inactive, deactivatedAt), and optional links: ghlUserId, clerkOrgId (→ partnerOrganizations), prospectingPartnerId, ghlAffiliateId. Admin-managed; a person holding several hats is one payee with multiple links, not multiple payees. Global (no locationId), like processingTeam — location-scoping would recreate multiple payees per person across locations.
  • Organizations: reuse partnerOrganizations for partner orgs (post-P0-fix). Dynamic org creation: when a commission recipient is a partner with no org yet, an admin action creates the Clerk org + partnerOrganizations row through the existing onboarding pieces — deferred to the phase where portal visibility for new partners matters (Phase 6); not required for ledger correctness.
  • One person, multiple orgs: supported naturally — Clerk allows multi-org membership; payees links to one payout identity while portal visibility follows Clerk membership. Commission entries reference the payee, so historical ownership is unaffected by org membership changes.
  • Tax/payment/onboarding status: held by Everee, not us. We store only payoutProviderAccounts.onboardingStatus (Everee worker status: ONBOARDING/ACTIVE/SEPARATED [everee]) and never SSN/bank/W-9 data — Everee’s hosted onboarding means “PII never touches your servers” (everee.com/technology) [everee].
  • Deactivation: payees.status = inactive blocks new assignments and new entries; existing payable entries pause (held) pending admin decision; history intact. Everee-side separation is mirrored from worker.* webhooks.
  • Historical ownership: guaranteed by append-only assignments + entry snapshots — reorganizations never rewrite the past.

11.2 Entity distinctions (summary)

User (login identity) ≠ GHL user (CRM seat, synced cache) ≠ Payee (money/role hub) ≠ Partner/Employee/Contractor (payee kinds) ≠ Organization (Clerk org for portal access) ≠ Commission recipient (= payee, by reference) ≠ External affiliate account (CAM cache row, attribution only). Hybrid: separate identity systems, one hub entity, kinds-as-field rather than tables-per-kind.

12. Everee API research (official citations)

Spelling confirmed: Everee (everee.com), active; October 2025 “Flex Platform” brand refresh, not a rename (blog) [everee]. Product lines: Flex Pay, Flex Suite, Flex Build (embedded/white-label payroll APIs — the relevant offering), Flex Credit. Fit assessment: the required use cases — pay contractors on demand, pay employees flat commission amounts (off-cycle if needed), hosted onboarding so we never hold PII, webhook-driven settlement status, deterministic idempotency — are all verified-supported. Unverified items that materially affect design: sandbox mechanics, error schema, post-payout reversal API. See §21 provider questions. Key architectural consequence: tokens are per legal entity (EIN), obtained by a company admin from Everee’s own portal — so “the customer enters an API key in settings” means: MFM (and any future entity running payouts) creates the token in Everee’s Integrations Hub and pastes token + tenant ID into our admin settings. There is no per-GHL-location Everee key unless each location is its own EIN with its own Everee company instance [everee].

13. Settings and configuration experience

Placement: operator/admin-facing pieces follow the existing card-link pattern of app/(main)/settings/page.tsx (add cards + settings/<name>/page.tsx) [code]; money/provider configuration lives in admin (app/admin/*) because payouts are an MFM-level operation gated by isAdminActor. Minimum viable settings (per area):

14. Security, compliance, and reliability

  • Tenant isolation: every new tenant-scoped table has locationId + by_location* indexes (payees/payoutProviderAccounts are global hub tables — §15); HTTP edge uses requireSessionWithLocation; credential/money routes additionally use the stricter rejectIfCrossLocationUnlessAdmin guard (app/api/mcp-keys/route.ts:33-50) [code]. Phase 0 closes the portal P0 (location-binding verification: the locationId must have a ghlInstallations row AND binding requires super-admin approval or a verified proof — see Phase 0).
  • Org isolation: portal queries remain Clerk-org_id-checked (existing getAuthorizedPartnerOrganization pattern) and add a uniqueness guard on bound locationId.
  • Role-based authorization: all new writes via actor + isAdminActor (operator/admin) or Clerk identity (portal). Commission approval and payout submission are admin-only; assignment writes allowed to location operators.
  • Secret storage: Everee token + webhook signing key as Convex deployment env vars for v1 (single MFM tenant; matches ENGINE_BEARER_TOKEN/STRIPE_* precedent [code]), referenced — never stored — by payoutProviderConfig (which holds tenant ID, sandbox flag, enablement, last-test result). If per-entity keys later multiply, migrate to encrypted-at-write table rows (envelope encryption with a Convex-env master key) — deferred. API key masking: the token never passes through our UI or tables — admins set the Convex env var directly; the settings card confirms configuration by showing the last-4 read from the env var.
  • PII/banking: never collected — Everee hosted onboarding owns SSN/bank/tax data [everee]. Our DB stores names/emails/amounts/worker IDs only.
  • Webhook signature verification: Everee HMAC-SHA256 over {timestamp}.{raw body}, constant-time compare, multi-signature rotation support [everee] — reuse convex/lib/stripeWebhookVerify.ts, which already implements this exact scheme (v1= multi-signature parsing, {timestamp}.{body} HMAC, constant-time compare, staleness tolerance) for three webhook endpoints; only the tolerance window (~2 min vs 5 min) needs parameterizing [code]. (lib/utils/verify-webhook-signature.ts is payload-only HMAC with no timestamp scheme — the wrong template.)
  • Replay protection: reject Everee timestamps older than ~2 min [everee] + dedupe on event id in providerWebhookEvents. For GHL triggers (which lack delivery IDs [code]), the evaluation/entry idempotency keys are the replay guard.
  • Idempotency / double-payment prevention (layered): (1) one commission idempotency namespace; (2) payoutItems uniqueness — an entry can be attached to at most one non-failed payout (lookup-before-insert); (3) Everee payable externalId = our payout ID (provider-side upsert dedup [everee]); (4) sideEffectStartedAt marker before the provider call (crash between call and write → reconciliation, never resubmission — partnerEnrollmentRequests pattern [code]); (5) Everee’s own portal approval gate as the final backstop [everee].
  • Reproducibility: calcSnapshot on every entry; plans immutable per version.
  • Audit logs: append-only event tables for assignments, entries, payouts; actor = adminActorAuditId convention [code].
  • Manual adjustment controls: reason-required mutations (pattern: operatorInvoices.writeOff [code]).
  • Approval workflows / separation of duties: entries: creation (system) → approval (admin) → payout submission (admin) — v1 enforces statuses, and adds one SoD rule: the actor who manually created or adjusted an entry cannot be the sole approver of it (checked in the approve mutation). Everee’s portal approval is a second, provider-side human gate.
  • Rate limiting: Everee 200 ops/5s [everee]; the dispatch queue drains in small batches (≤20/run) with backoff on 429 honoring RateLimit-Reset.
  • Provider outages / retry queues / dead-letter: clone the billingRetryQueue state machine (pending/processing/success/failed/exhausted/audit_failed, 15-min lease, 5/15/45/135-min backoff) for payout dispatch; exhausted and audit_failed raise billingAlerts-style admin alerts [code]. Safe failure = payouts stay approved/submitted, entries stay payable; nothing is marked paid without provider confirmation.
  • CAM unavailability: sync skips (already the 401/403 behavior [code]); attribution falls back to leadEvents fields; nothing blocks.
  • Reconciliation jobs & observability: §20.

15. Data model

New tables (all in convex/schemas/ modules; integer cents for all money; every table locationId-scoped unless noted). Against the checklist in the task: we recommend not creating separate InternalUser, Role (beyond a small registry), RoutingRuleVersion, CommissionPlanVersion, CommissionSplit, PayoutRecipient, or ExternalProviderMapping tables — their jobs are absorbed by existing tables, same-table versioning, split-legs-as-entries, payees, and link fields, respectively. Smallest coherent model: Ownership boundaries: GHL owns contacts/opportunities/users; Clerk owns portal identities/org membership; Everee owns worker PII/tax/bank + payment execution; Convex owns assignments, rules, the ledger, payout intent/state, and all audit trails.

Lifecycles (state machines)

  1. New referred lead: ContactCreate webhook → leadEvents row (attribution) → scheduled routing evaluation → assignments (or no_match).
  2. Lead assigned by rule: evaluation appliedroleAssignments active rows (+ optional GHL assignedTo mirror) → visible on applicant views + “Why?” popover.
  3. Applicant → client: human moves/creates opp in GHL → assignments unchanged (contact-anchored) → client_materialized event → optional re-mirror + eligibility trigger.
  4. Commission earned: InvoicePaid (basis = amountPaid) → engine resolves active assignments + active plan versions → entries inserted pending (idempotent) with snapshots.
  5. Commission payable: admin approves (pending→approved); approved entries become payable automatically when eligibility clears (fulfillment gate) or immediately if none.
  6. Payout sent: admin builds payout (payee’s payable entries) → draft→approved → queue row → Node action: upsert payable (externalId = payout id) + payment-requestsubmitted (entries stay payable, now attached).
  7. Payout succeeds/fails/retries/reverses: payment-payables.status-changed/payment.paid webhooks drive processing→paid (entries → paid) or →failed (entries detach, back to payable; queue retries transient failures; exhausted alerts). payment.deposit-returned → payout returned, entries → compensating reversal + admin alert. HTTP failure after sideEffectStartedAtaudit_failed → reconciliation resolves via payables/pay-history reads, never resubmits blindly.
  8. Recipient deactivated: payee inactive → assignments flagged for reassignment, no new entries, payable entries → held; Everee SEPARATED mirrored via webhook.

16. Integration and event flows

GHL user sync: hourly cron (kill switch GHL_USER_SYNC_ENABLED) per installed location with valid tokens → getUsersByLocation (existing Convex fetch helper convex/lib/ghlApi/users.ts:61 [code]) → diff vs ghlUsers → insert new / patch changed / mark absent missing (missingSince) → nightly integrity check flags active assignments referencing missing users. On-demand “Refresh now” runs the same action for one location. Rate-limit friendly: locations processed in batches with self-continuation. Applicant creation: unchanged (existing webhook path) + one added step: schedule routing evaluation after leadEvents write (app/api/webhooks/new-lead/route.ts / lib/webhooks/contact-create-handler.ts [code]). Referral attribution: at evaluation time, resolver maps (referralOrgId → payee by clerkOrgId) ∥ (prospectingLeads.partnerId → payee) ∥ (CAM ghlAffiliateCommissions.by_location_contact → payee by ghlAffiliateId, when sync enabled) → stored on the evaluation record as attributedPayeeId. Dynamic assignment: evaluation mutation (single transaction): idempotency check → load enabled rules by priority → first match → per action: active-assignment check (manual-wins, replaceExisting) → end/insert assignment rows + events → write evaluation record → optionally schedule GHL assignedTo mirror action (outside the transaction; failure retried, never blocks assignment). Applicant→client conversion: detector (webhook tag/stage signal or piggybacked on existing opportunity fetch) → first Clients-pipeline opp or Won - Signed for a contact → client_materialized evaluation trigger → re-mirror + eligibility hook. No data copying. Commission calculation: InvoicePaid handler (already parsing amountPaid for the operator fee [code]) additionally schedules internal.commissions.processRevenueEvent → loads active assignments for the contact + active plan versions per roleKey → computes legs (splits share splitGroupId) → inserts entries idempotently → any duplicate key = no-op. Commission approval: admin ledger view → approve selected (SoD check: approver ≠ sole manual creator) → status events appended → auto-promote to payable when eligibility clears (fulfillment resolver reuse [code]). Everee payout creation: admin “Create payout” per payee (or period sweep) → validates provider account active → payout draft + items → admin approve → enqueue → drain cron claims lease → Node action: POST /api/v2/payables (upsert, externalId = payout id) → POST /api/v2/payables/payment-request → mark submitted + store evereePayableId. 429 → backoff per RateLimit-Reset [everee]. Everee webhook processing: POST app/api/webhooks/everee → verify HMAC ({timestamp}.{body}, constant-time, multi-sig) → reject stale timestamps → dedupe on event id in providerWebhookEvents → route by type: payable status → payout/entry transitions; worker.onboarding-completed → provider account active; payment.deposit-returned → reversal flow. Always 2xx fast; processing is scheduled, not inline [everee]. Reconciliation: daily cron: (a) list unpaid payables + recent pay history from Everee for our window; (b) compare against payouts (by externalId) and detect: submitted-but-unknown-to-Everee, Everee-paid-but-local-not-paid (heal), local-audit_failed (resolve), amount mismatches (alert); (c) reconciliationRuns + findings + admin alert on any discrepancy. Secondary: CAM commission totals vs internal partner entries (report-only). Duplicate event handling: GHL — handler idempotency keys (no transport IDs available [code]); Everee — event-id dedup + payable-externalId upsert + our entry/payout uniqueness. All three layers must independently hold. Provider outage recovery: dispatch failures backoff → exhausted alerts; webhook gaps healed by reconciliation (pay-history is the recovery read [everee]); Everee down ⇒ payouts pause in approved, ledger unaffected; GHL down ⇒ user sync/mirrors skip (existing token-refresh resilience [code]), assignments/ledger unaffected.

17. Phased implementation plan

Order rationale: security fix → read-only foundations → assignments → rules → ledger schema → calculation → surfaces → provider foundation (sandbox) → payout writes (sandbox) → production enablement. Phases 1–4 are useful even if payouts never ship. VISION gates: every phase with schema changes, auth changes, billing math, or payments is Out of Scope for autonomous agents and requires explicit Brock approval (VISION.md:14-19); this plan cites that approval per phase once granted.
Phase 0 — Close the portal cross-tenant P0. Objective: no unverified locationId binding. Scope: convex/partnerOrganizations.ts (create, add server-side validation + locationId uniqueness + verified-binding requirement: location must exist in ghlInstallations AND binding requires super-admin approval — new pendingLocationBindings flow or admin-only bind mutation; also auth checkSlugAvailability), app/partner-portal/getting-started/page.tsx (submit → “pending approval” state), and read-side enforcement in convex/partnerEarnings.ts: getAuthorizedPartnerOrganization (:13) returns null unless the binding is verified (locationVerifiedAt set), with every portal function routed through it (replacing the inline org checks in getByClerkOrg/getBranding/updateBranding) — the write-path fix alone leaves rows bound before the fix readable. Schema: optional partnerOrganizations.locationVerifiedAt/verifiedBy fields (additive). Migration: audit existing rows for suspect bindings (read-only report first). Flags: none — this is a fix. Tests: auth tests modeled on tests/adminAuth.test.ts (bind rejection, cross-org read rejection, slug-auth). Rollout: immediate; Rollback: revert commit (no destructive migration). Risks: legitimate existing partners with unverified bindings — the audit report resolves before enforcement. Acceptance: neither unauthorized create with an arbitrary locationId nor a pre-existing unverified binding yields earnings reads. (The adjacent unauthenticated public writers into these tables were closed separately in PR #1206.) Approval: REQUIRED (auth + schema). Phase 1 — GHL user directory sync. Objective: synced, queryable ghlUsers per location. Scope: convex/schemas/ new module (ghlUsers), convex/ghlUsers.ts (sync action reusing convex/lib/ghlApi/users.ts, queries), cron in convex/crons.ts behind GHL_USER_SYNC_ENABLED (ships false), settings surface stub. Backfill: first sync run is the backfill. Tests: diff logic pure-function tests (bun); missing-user marking. Verification: read-only — compare synced rows against GET /api/ghl/users output for a dev location. Rollback: disable env flag; table is inert. Dependencies: none. Risks: token-invalid locations skip (existing behavior [code]); marketplace-flow installs lack users.readonly scope (app/api/lb/authorize/route.ts:19 [code]) — verify effective scopes on a dev install; if absent, add the scope in the GHL Marketplace app config and to the authorize route (re-consent required for existing installs — [unknown] whether GHL grants new scopes without reinstall; confirm). Acceptance: users listed with status for a flagged location. Approval: REQUIRED (schema; possible scope change). Phase 2 — Roles, payees, assignments. Objective: contact-anchored role assignments with audit; conversion-persistence achieved by design. Scope: schema (roleDefinitions, payees, roleAssignments, roleAssignmentEvents); mutations/queries (actor-gated, convex/roles/*); UI: assignment section on /contact page + components/applicants|clients bulk action + Team Roles settings page; per-location flag locationSettings.featureFlags.rolesEnabled. Migration/backfill: optional assignedTo seeding via convex/oneOffMigrations.ts (dry-run mode first). Tests: one-active-per-(subject,role) invariant; manual-wins; auth (operator vs cross-location vs portal); backfill dry-run counts. Rollout: flag one internal location. Rollback: flag off (data inert). Dependencies: Phase 1 (picker data). Risks: UI clutter on contact page (collapsed section). Acceptance: assign/reassign/end with history; assignments visible on both applicant and client views for a converted contact. Approval: REQUIRED (schema). Phase 3 — Routing rules engine. Objective: §8 semantics end-to-end. Scope: schema (routingRules, routingRuleEvaluations); pure evaluator in convex/lib/routingEngine.ts; scheduled evaluation wired into lib/webhooks/contact-create-handler.ts + app/api/webhooks/new-lead/route.ts; settings UI (settings/routing-rules); “Why assigned?” popover; optional GHL assignedTo mirror (sub-flag, reusing leadReassignment’s write pair [code]). Flags: routingEnabled per location; mirror sub-flag. Tests: evaluator pure tests (priority, first-match, manual-wins, replaceExisting, fallback, idempotency-duplicate) — the highest-value test surface; webhook-path integration test. Verification: dry-run panel against recent real leads (read-only). Rollback: flag off; evaluations stop; assignments keep history. Dependencies: Phase 2, plus Phase 4’s identity-linking slice (payee ↔ clerkOrgId/prospecting/CAM links) for the referring-partner condition — without links every partner-referred lead resolves no_match and §8.4 Case A cannot pass its acceptance test; land Phase 4’s linking CRUD first or pull it forward into this phase (source/campaign/tag conditions work without it). Risks: attribution resolver gaps (unlinked partners) → no_match + visible log, not silent failure. Acceptance: the three example rules (§8.4) behave as specified on a test location. Approval: REQUIRED (schema; webhook-path touch). Phase 4 — Payees↔identities completion + commission plans (schema + settings). Objective: plan authoring without calculation. Scope: schema (commissionPlans); plan editor UI (admin); payee admin CRUD; identity linking (Clerk org, prospecting partner, CAM affiliate). Tests: plan version immutability (edit ⇒ new version); resolution ladder (payee override → role plan). Rollback: UI flag off. Dependencies: Phase 2. Risks: low (no money math yet). Acceptance: versioned plans with effective dates authorable. Approval: REQUIRED (schema). Phase 5 — Commission ledger + calculation engine. Objective: entries created from InvoicePaid, idempotently, with snapshots; approval workflow. Scope: schema (commissionEntries, commissionEntryEvents); convex/commissions/engine.ts (pure calc + snapshot builder) + processRevenueEvent internal mutation; hook into lib/webhooks/invoice-paid-handler.ts (additive consumer of the same event [code]); admin ledger UI (approve/hold/cancel/manual entry with reason; SoD check); flag commissionsEnabled per location + COMMISSIONS_ENGINE_ENABLED env switch. Backfill: none by default; optional historical backfill is a separate approved one-off (dry-run first). Tests (highest-risk): idempotent replay of the same invoice event; split math sums exactly to source (no cent leakage — largest-remainder allocation); plan-version selection at earn time; reversal-of-paid compensating entry; eligibility gating; dollars→cents conversion at the amountPaid boundary. Verification: shadow mode — engine runs with dryRun env flag writing to a log-only path for N days; compare against admin-tracking computed values. Rollback: env switch off; entries remain (append-only). Dependencies: Phases 2–4. Risks: double-emission if a second emitter is added later — mitigated by the single namespace; unit mistakes — mitigated by cents-only schema + boundary tests. Acceptance: paid invoice → correct pending entries for all assigned roles; replay-safe. Approval: REQUIRED (schema + billing/fee math). Phase 6 — Reporting surfaces + portal rewire. Objective: truthful earnings everywhere. Scope: rewire convex/partnerEarnings.ts queries to commissionBalances for totals + paginated commissionEntries for history (payee by clerkOrgId), keeping response shape for app/partner-portal/earnings/page.tsx; admin per-location/per-payee ledger reports; earned-vs-paid split displayed. Tests: org-scoping auth; earned/paid segregation. Rollback: query-level revert. Dependencies: Phase 5. Risks: portal previously showed empty data — numbers appearing is a product comms event, not a code risk. Acceptance: portal + admin match ledger. Approval: recommended (user-facing money display). Phase 7 — Everee foundation (no money movement). Objective: configured, tested, sandbox-linked provider; worker onboarding kicked off. Scope: convex/lib/evereeClient.ts (Node action helpers: Basic auth + tenant header, Base64 for sk_, rate-limit-aware); payoutProviderConfig + payoutProviderAccounts schema; admin settings card (tenant ID entry + env-var setup instructions, last-4-from-env display, Test connection = GET /integration/v1/workers); hosted-onboarding kick-off action (POST /api/v2/onboarding/contractor / employee) + worker.* webhook handling; app/api/webhooks/everee route with full signature/replay/dedup stack; providerWebhookEvents schema. External: create Everee sandbox instance (via Everee sales/Partner Success — mechanics [unknown]), configure webhook endpoint + signing key. Flags: NEXT_PUBLIC_PAYOUTS_MODULE_ENABLED (UI) + EVEREE_ENABLED (server; ships false) + payoutProviderConfig.sandbox=true enforced until Phase 9. Tests: signature verifier vectors (valid/invalid/stale/rotated); event dedup; client unit tests against recorded fixtures (contract tests). Verification: sandbox-only; never run onboarding against a live tenant. Rollback: flags off. Dependencies: Phase 4 (payees). Risks: sandbox availability timeline — start the Everee conversation at Phase 5 time. Acceptance: green connection test; sandbox contractor onboarded end-to-end via hosted link; webhooks verified + deduped. Approval: REQUIRED (payments area + schema + new external provider). Phase 8 — Payout execution (sandbox). Objective: payable→paid loop in sandbox. Scope: schema (payouts, payoutItems, payoutDispatchQueue); build/approve/submit mutations with SoD; dispatch drain cron (10 min, PAYOUT_DISPATCH_ENABLED kill switch, ships false); status webhooks → transitions; failure/return/reversal handling; admin payout UI. Tests (highest-risk): crash-after-sideEffectStartedAt → reconciliation not resubmission; entry-attached-twice prevention; externalId collision replay (provider upsert = no double pay); deposit-returned → compensating reversal; queue exhaustion → alert; 429 backoff. Verification: full sandbox cycle with fake workers; assert Everee portal shows the approval-gated payable. Rollback: kill switch; in-flight payouts settle via webhooks/reconciliation. Dependencies: Phases 5, 7. Risks: unknown Everee error schema → defensive parsing + raw-body capture on payoutDispatchQueue failures. Acceptance: sandbox payout reaches PAID and entries flip paid; every failure path lands in a defined state with an alert. Approval: REQUIRED (payments). Phase 9 — Reconciliation + observability. Objective: the system heals and reports. Scope: daily reconciliation cron (§16) + reconciliationRuns/Findings schema + admin discrepancy surface; alerting on exhausted/audit_failed/discrepancies (reuse billingAlerts shape [code]); metrics counters (entries created/approved/paid, payout latency). Tests: each discrepancy class detected from fixture states; heal path for Everee-paid/local-unpaid. Dependencies: Phase 8. Acceptance: induced sandbox discrepancies all surface within one run. Approval: REQUIRED (schema). Phase 10 — Production enablement. Objective: live tenant, gradual rollout. Scope: create live Everee token/tenant (Integrations Hub [everee]); Convex env vars set; sandbox=false; enable per-location flags for MFM’s own location first; CAM sync enablement decision (GHL_AFFILIATE_SYNC_ENABLED + scope rollout) as a separate toggle; runbook doc (docs/sops/). Rollout: internal payees first → one real contractor with a $1 payout → staged expansion; keep PAYOUT_DISPATCH_ENABLED as the instant kill switch; Everee portal approval remains on as the human backstop. Rollback: kill switch + entries revert to payable. Acceptance: first real payout reconciles cleanly. Approval: REQUIRED (production money movement) — plus product sign-off on the SOP replacing docs/SOP-Paying-Commissions.md’s manual Gusto flow.

18. Testing strategy

Repo reality: Bun’s bun test over ~241 files; Convex logic is tested by extracting pure helpers + source-text assertions; convex-test is documented in guidelines but effectively unused; auth is tested at the HTTP-route layer (tests/adminAuth.test.ts et al.) [code]. Strategy follows that grain — every engine (routing evaluator, commission calculator, snapshot builder, payout state machine, signature verifier, reconciliation differ) is a pure function in convex/lib/, unit-tested exhaustively; Convex handlers stay thin. Concrete highest-risk cases: (1) InvoicePaid replay + backfill emitter running the same deal — the AGENTS.md (Learned Workspace Facts: double-billing pitfall) double-billing scenario re-tested against the unified namespace; (2) crash between Everee payment-request success and local submitted write — must resolve via reconciliation without a duplicate payment; (3) rule edit racing a webhook evaluation — snapshot on the evaluation must reflect the version actually evaluated; (4) partner deactivated between approved and dispatch — the dispatch claim re-checks payee status and must halt (payout → canceled, its entries detach and go held per §15 lifecycle 8), never paying an inactive payee.

19. Migration and rollout plan

  • No destructive migrations anywhere in the plan. All schema is additive; vestigial tables (salesCommissions, closerCommissions, organizations) are left in place and their retirement proposed only after Phase 6 proves the replacement (separate Brock-approved deletion plan, per the Zoho-retirement precedent [code]).
  • One-off backfills (assignment seeding, optional historical commissions) registered in convex/oneOffMigrations.ts [code], each with a dry-run mode and per-location scoping.
  • Flag ladder per location: rolesEnabledroutingEnabledcommissionsEnabled → payouts (global env + admin config). Server kill switches: GHL_USER_SYNC_ENABLED, COMMISSIONS_ENGINE_ENABLED, EVEREE_ENABLED, PAYOUT_DISPATCH_ENABLED — all ship false (the GHL_AFFILIATE_SYNC_ENABLED convention [code]).
  • Convex deploys follow the repo rule: bunx convex deploy after schema changes, commit generated types (CODEBASE.md conventions [code]).
  • Rollback stance: flags off stops behavior; append-only data is inert; the only irreversible action in the entire plan is a real Everee payment, which is quadruple-gated (flag, admin approval, dispatch switch, Everee portal approval).

20. Observability and reconciliation

  • Alerts (reuse billingAlerts table+banner pattern [code]): dispatch exhausted/audit_failed; webhook signature failures above threshold; reconciliation discrepancies; assignments referencing missing GHL users; payee deactivated with payable balance.
  • Dashboards/queries (admin): entries by status per location; payout pipeline (draft→paid latency); queue depth + oldest nextRetryAt; last successful sync/reconciliation timestamps; evaluation outcomes histogram (no_match spikes = attribution gaps).
  • Reconciliation cadence: daily provider reconciliation (§16); nightly assignment-integrity job; weekly CAM-vs-ledger partner report (report-only). Every run persisted (reconciliationRuns) so “did it run” is itself observable — the Zoho mirror health-check cron is the precedent [code].
  • Tracing: store Everee request_id response headers on dispatch attempts for support escalation [everee].

21. Open decisions

  1. Option C hybrid ledger (§10); integer cents; append-only events.
  2. Contact-anchored assignments; mirror exactly one role to GHL assignedTo (default funding_assistant), flag-gated.
  3. First-match explicit-priority rules; manual-wins; queued evaluation; new-leads-only by default.
  4. Everee token as Convex env var (single MFM tenant) referenced by config row; hosted onboarding (never collect PII).
  5. InvoicePaid.amountPaid as the sole v1 commission basis event.
  6. Rule/plan versioning via snapshots + same-table versions (no separate version tables).

Product decisions Brock must approve

  1. Phase 0 enforcement posture: hard-block unverified location bindings vs grandfather existing rows after audit.
  2. Role taxonomy: are assistant/advisor/manager/partner/employee/contractor the right seed set, and which role mirrors to GHL assignedTo?
  3. Commission plan semantics per role (amounts/percentages/tiers) and whether partners are paid via Everee-as-1099-contractors or remain outside payout execution in v1.
  4. Approval workflow depth: single admin approval + SoD rule (proposed) vs two-person approval for payouts above a threshold.
  5. Whether enabling CAM sync in prod (scope + flags) is in-scope for this initiative or stays a separate decision.
  6. Replacing the manual Gusto SOP (docs/SOP-Paying-Commissions.md) — timing and comms.
  7. Portal earnings will change from empty/legacy to real numbers at Phase 6 — partner communication plan.

Provider questions requiring Everee confirmation

  1. Sandbox: how obtained, base URL, key format, feature parity [unknown].
  2. Error response schema and documented retry guidance [unknown].
  3. Post-payout corrections: any void/reversal API, or portal/support-only? Exact payment.deposit-returned payload [unknown].
  4. Can portal-side payment approval be disabled/automated for API-submitted payables, or is it mandatory (affects payout latency)? [unknown]
  5. Confirm US-only; any state restrictions for our contractor mix [unknown].
  6. White-label/Flex Build commercial terms if we ever run payouts per-customer-EIN [unknown].
  7. Webhook signing-key rotation procedure and sandbox webhook support [unknown].

Technical questions that can be deferred

  1. Encrypted-at-write token table (needed only if provider keys multiply beyond env vars).
  2. Effective-dated routing rules; full rule version browser.
  3. FUND-1921 sub-partner override chains atop commissionEntries.
  4. Unified audit-log browser across domains.
  5. Whether processingTeam/autoAssign should migrate onto payees/roleAssignments (parallel systems are acceptable initially).

Risks that could materially change the architecture

  1. GHL marketplace scope changes requiring re-consent: if adding users.readonly (and optionally affiliate-manager.readonly) forces reinstall for existing locations, Phase 1’s sync coverage and the CAM decision both shift — verify on a dev install first [unknown].
  2. Everee approval gate immovable + slow: if every API payout requires manual Everee-portal approval with meaningful latency, the payout UX becomes “stage then approve in Everee,” which changes the admin surface design [unknown].
  3. No sandbox access in reasonable time: Phases 7–8 would need a stub-provider adapter to keep momentum (the adapter boundary already supports this).
  4. Multi-EIN reality: if payouts must run under multiple legal entities, config becomes per-instance (token, tenant, webhooks ×N) — the schema anticipates it (payoutProviderConfig per provider row; accounts carry evereeTenantId) but settings UX would grow.
  5. Basis-event correctness: if commissions must key off events other than InvoicePaid (e.g., funding disbursement recorded outside GHL invoices), the engine’s trigger layer needs that event source built first.

22. Explicit assumptions and confidence levels


Planning-only deliverable: no application code, schema, configuration, environment variables, external accounts, webhooks, payees, payouts, or production data were created or modified. The only file added is this document.