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 GHLcontactId.
This plan proposes:
- A synced GHL user directory (
ghlUsers) plus a payee registry (payees) that unifies partners, employees, contractors, and GHL users as commission recipients. - Contact-anchored role assignments (
roleAssignments) with append-only history, so assignments survive applicant→client conversion by construction rather than by copying. - 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.”
- 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]. - An Everee integration modeled on the repo’s strongest existing reliability pattern (
billingRetryQueue: lease claims, exponential backoff,audit_failedreconciliation state) — verified against official Everee docs: company-tenant-scoped API tokens, hosted worker onboarding (SSN/bank data never touches our servers), Payables API withexternalIdupsert idempotency, HMAC-SHA256 signed webhooks, and an admin-approval gate before money moves [everee]. - 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.
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
salesCommissionsfiltered by the org’s boundlocationId(convex/partnerEarnings.ts:32-102). There is nopartnerEarningstable. - Cross-tenant P0:
partnerOrganizations.create(convex/partnerOrganizations.ts:197-257) inserts caller-suppliedlocationIdwith no ownership verification; onboarding validates only^[a-zA-Z0-9]{10,}$client-side (app/partner-portal/getting-started/page.tsx:148). No uniqueness guard onlocationIdeither. 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; theorganizationstable (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) andcloserCommissions(:354-381) have writers (recordCommissionatconvex/salesCommissions.ts:4-70,recordCloserCommissionatconvex/closerCommissions.ts:314, plusmarkCommissionsAsPaid/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 anylocationId(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 tointernalMutation. 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; 50 sold; Wed–Tue pay period) [code]. docs/design/fund-1921-subpartner-commissions.mdis 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
ghlUserstable. Users are fetched read-through only:getGHLUsersByLocation(lib/ghl-sdk.ts:1622),convex/lib/ghlApi/users.ts:61, andapp/api/ghl/users/route.ts(mentions picker). The only assignable-user registry is the hand-curatedprocessingTeamtable (convex/schemas/agencyOps.ts:449-458). - Scopes diverge by authorize route:
app/api/ghl/authorize/route.ts:25includesusers.readonly/users.writeand payments scopes;app/api/lb/authorize/route.ts:19(the marketplace flow) does not.affiliate-manager.readonlyis 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 locationoE9ILHco0XXZUu79wAA0[live].) - Routing substrate exists but is dormant:
roundRobinState(convex/schemas/core.ts:45-50),leadReassignmentAudits(:52-67), andconvex/leadReassignment.ts(selectNextAssignee:565, writesopportunity.assignedTo+contact.assignedToin GHL) — its driving cron was removed 2026-06-30.convex/autoAssign.tsis a live load-balancer for processing submissions overprocessingTeam. - Webhook transport has no dedup/replay protection for GHL events. The
webhookEventstable 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.tswraps 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 bycontactId+locationId. - There is no conversion mutation. A client is either an applicant opp moved to
Won - Signed(rendered on/clientsas syntheticAgreement Signed,lib/hooks/useClients.ts:390-402) or a new Clients-pipeline opportunity created by a human in GHL.ensureApplicantOpportunityHandler(convex/lib/underwritingGhlSync.ts:274onward; 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,referralOrgId—convex/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 carriesuserId/email/role/locationId/companyId/type(lib/auth/session.ts:29-37) butroleis never enforced. - Convex
ctx.auth.getUserIdentity()is populated only for Clerk (portal) requests (convex/auth.config.ts); operator/admin apps pass a verifiedactorobject intointernal*functions checked byisAdminActor(convex/lib/adminActor.ts:12-26) — the pattern any new admin-gated write must follow (AGENTS.md, Admin / iframe auth pattern). - Tenant isolation =
locationIdarg +by_locationindex convention, enforced at the HTTP edge byrequireSessionWithLocation(lib/auth/require-session.ts:141-183). Agency-type sessions get cross-location access;app/api/mcp-keys/route.ts:33-50added a stricterrejectIfCrossLocationUnlessAdminguard — reuse it for anything credential- or money-related. - Secrets today: (1) Convex deployment env vars (
ENGINE_BEARER_TOKEN,STRIPE_*,DUB_API_KEY); (2) themcpApiKeyshashed 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.operatorInvoicesidempotent reserve→create→send keyed onsourceClientInvoiceId, immutablefeeBasisSnapshot, durablemfmInvoiceSendAttemptedAtpre-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 +sideEffectStartedAtreplay 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
- Sync GHL users per location and make them (and non-GHL people) assignable to applicants, clients, and referral leads in defined internal/external roles.
- Guarantee role assignments persist across applicant→client conversion.
- Let admins define simple routing rules (“partner X refers → assign assistant Y / advisor Z / manager A”) with explainability, audit, and manual override.
- 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.
- Execute payouts through Everee for employees and contractors (and optionally partners-as-contractors), with idempotency, retries, reconciliation, and clear separation of earned vs paid.
- Keep GHL Commission/Affiliate Manager as a read-only attribution/reporting input, not a system of record.
- 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.
5. Recommended architecture
contactIdis the assignment anchor — assignments survive conversion because both applicant and client opportunities share it (§2.5). GHLopportunity.assignedTobecomes a projection we optionally write, never the source of truth.- 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].
- Read-only external systems stay read-only: GHL Affiliate Manager feeds attribution/reporting; we never attempt writes (the API forbids them anyway) [code].
- Money movement is queued, leased, retried, and reconciled using the
billingRetryQueuepattern, with an explicit admin approval gate before submission — which composes with Everee’s own portal-approval gate [everee]. - Everything ships dark: per-location DB flags for role/routing features (like
locationSettings.featureFlags), Convex env kill switches for crons (likeGHL_AFFILIATE_SYNC_ENABLED), and a build-timeNEXT_PUBLIC_*gate for new UI surfaces (likelib/partnerModuleFlag.ts).
6. GHL user and role assignment design
6.1 Storage: what identifies the assignee?
Decision: assignments referencepayeeId (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
usersrows alone break for people who never log in (contractors). - A
roleAssignmentsrecord referencing apayeeshub gives one stable internal ID while preserving the external join keys (ghlUserIdfor CRM writes,evereeWorkerIdfor payouts).
6.2 The questions, answered
6.3 Mirroring into GHL (optional projection)
Writingopportunity.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:- Contact-anchored assignments (primary mechanism).
roleAssignments.subjectId = contactId. The/clientsview resolves assignments bycontactIdexactly as/applicantsdoes. Nothing is copied, so nothing can be lost. No migration of GHL data required. - Client-materialization detector (secondary, for GHL mirroring + commissions). A lightweight check — piggybacking on the existing opportunity fetches and/or the
ContactTagUpdate/pipeline webhooks — records aclient_materializedevent on the evaluation log when a contact first gains a Clients-pipeline opportunity or reachesWon - Signed. This event (a) triggers re-projection of the mirrored role onto the new client opportunity’sassignedTo, and (b) is a commission-eligibility trigger candidate (§9). - Backfill behavior (migration). One-off internal mutation (registered in
convex/oneOffMigrations.tsper convention [code]) that, per location and only when the feature flag is on, seedsroleAssignmentsfrom current GHLopportunity.assignedTovalues (role = the location’s designated mirror role,source: "conversion_backfill"). Read-only dry-run mode first (returns counts, writes nothing). - 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.
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), leadsource(exact/contains), Dub campaign (partnerReferralLinks.campaignName), tag (from ContactTagUpdate). All optional; empty = match-all (fallback only). - Actions: assign
[{roleKey, payeeId}](bounded ≤5 pairs); optionalalsoMirrorToGhlAssignedTo: boolean.
8.4 Three example rules and their evaluation
LocationL1 rules (priority order):
- P10 — enabled — IF referring partner = Acme Partners THEN assign
funding_assistant → Yolanda,funding_advisor → Zach. - P20 — enabled — IF source contains “prospecting” THEN assign
funding_assistant → Aaron(replaceExisting: true). - P99 — enabled, fallback (no conditions) — assign
funding_manager → Brock.
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 existingsetterCommissionSettings→locationCommissionDefaultsresolution 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(reuseresolveFeeRowFulfillment/ the 7-Figures completion gate semantics [code]),minCollectedCents, trigger event type. - Splits: a plan may define
splitPolicydistributing one earning event across the subject’s role holders (e.g., advisor 60% / assistant 40%); each split leg is its own entry sharing asplitGroupId.
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].sourceEventIdmust be a durable domain identifier (the GHL invoice ID forinvoice_paid, the contactId forclient_materialized, an admin-supplied reference formanual) — 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:
calcSnapshotobject 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, plusheld,disputed,reversed,canceled. Transitions only via internal mutations that also append acommissionEntryEventsrow (actor, reason, from→to). No status is ever skipped silently;paidis set only by payout settlement (§13 flows). - Clawbacks/reversals: never mutate a paid entry — insert a compensating negative entry (
reversalOfEntryId), mark the originalreversed. Unpaid entries may becanceleddirectly. (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:writeOffrequiring a reason [code]). - Earned vs paid separation: “earned” = entry exists in ≥
approved; “paid” = linkedpayoutItemsrow whose payout reachedpaid. 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 indexedby_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
11.1 Recommended entity 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 (nolocationId), likeprocessingTeam— location-scoping would recreate multiple payees per person across locations.- Organizations: reuse
partnerOrganizationsfor 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 +partnerOrganizationsrow 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;
payeeslinks 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 = inactiveblocks new assignments and new entries; existingpayableentries pause (held) pending admin decision; history intact. Everee-side separation is mirrored fromworker.*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 ofapp/(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/payoutProviderAccountsare global hub tables — §15); HTTP edge usesrequireSessionWithLocation; credential/money routes additionally use the stricterrejectIfCrossLocationUnlessAdminguard (app/api/mcp-keys/route.ts:33-50) [code]. Phase 0 closes the portal P0 (location-binding verification: thelocationIdmust have aghlInstallationsrow AND binding requires super-admin approval or a verified proof — see Phase 0). - Org isolation: portal queries remain Clerk-
org_id-checked (existinggetAuthorizedPartnerOrganizationpattern) and add a uniqueness guard on boundlocationId. - 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 — bypayoutProviderConfig(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] — reuseconvex/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.tsis payload-only HMAC with no timestamp scheme — the wrong template.) - Replay protection: reject Everee timestamps older than ~2 min [everee] + dedupe on event
idinproviderWebhookEvents. 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)
payoutItemsuniqueness — an entry can be attached to at most one non-failed payout (lookup-before-insert); (3) Everee payableexternalId= our payout ID (provider-side upsert dedup [everee]); (4)sideEffectStartedAtmarker before the provider call (crash between call and write → reconciliation, never resubmission —partnerEnrollmentRequestspattern [code]); (5) Everee’s own portal approval gate as the final backstop [everee]. - Reproducibility:
calcSnapshoton every entry; plans immutable per version. - Audit logs: append-only event tables for assignments, entries, payouts; actor =
adminActorAuditIdconvention [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
billingRetryQueuestate machine (pending/processing/success/failed/exhausted/audit_failed, 15-min lease, 5/15/45/135-min backoff) for payout dispatch;exhaustedandaudit_failedraisebillingAlerts-style admin alerts [code]. Safe failure = payouts stayapproved/submitted, entries staypayable; nothing is markedpaidwithout provider confirmation. - CAM unavailability: sync skips (already the 401/403 behavior [code]); attribution falls back to
leadEventsfields; nothing blocks. - Reconciliation jobs & observability: §20.
15. Data model
New tables (all inconvex/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)
- New referred lead: ContactCreate webhook →
leadEventsrow (attribution) → scheduled routing evaluation → assignments (orno_match). - Lead assigned by rule: evaluation
applied→roleAssignmentsactive rows (+ optional GHLassignedTomirror) → visible on applicant views + “Why?” popover. - Applicant → client: human moves/creates opp in GHL → assignments unchanged (contact-anchored) →
client_materializedevent → optional re-mirror + eligibility trigger. - Commission earned:
InvoicePaid(basis =amountPaid) → engine resolves active assignments + active plan versions → entries insertedpending(idempotent) with snapshots. - Commission payable: admin approves (
pending→approved); approved entries becomepayableautomatically when eligibility clears (fulfillment gate) or immediately if none. - Payout sent: admin builds payout (payee’s payable entries) →
draft→approved→ queue row → Node action: upsert payable (externalId= payout id) +payment-request→submitted(entries staypayable, now attached). - Payout succeeds/fails/retries/reverses:
payment-payables.status-changed/payment.paidwebhooks driveprocessing→paid(entries →paid) or→failed(entries detach, back topayable; queue retries transient failures;exhaustedalerts).payment.deposit-returned→ payoutreturned, entries → compensating reversal + admin alert. HTTP failure aftersideEffectStartedAt→audit_failed→ reconciliation resolves via payables/pay-history reads, never resubmits blindly. - Recipient deactivated: payee
inactive→ assignments flagged for reassignment, no new entries,payableentries →held; EvereeSEPARATEDmirrored via webhook.
16. Integration and event flows
GHL user sync: hourly cron (kill switchGHL_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’sbun 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:
rolesEnabled→routingEnabled→commissionsEnabled→ payouts (global env + admin config). Server kill switches:GHL_USER_SYNC_ENABLED,COMMISSIONS_ENGINE_ENABLED,EVEREE_ENABLED,PAYOUT_DISPATCH_ENABLED— all ship false (theGHL_AFFILIATE_SYNC_ENABLEDconvention [code]). - Convex deploys follow the repo rule:
bunx convex deployafter 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
billingAlertstable+banner pattern [code]): dispatchexhausted/audit_failed; webhook signature failures above threshold; reconciliation discrepancies; assignments referencingmissingGHL 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_matchspikes = 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_idresponse headers on dispatch attempts for support escalation [everee].
21. Open decisions
Recommended defaults (proceed unless overridden)
- Option C hybrid ledger (§10); integer cents; append-only events.
- Contact-anchored assignments; mirror exactly one role to GHL
assignedTo(defaultfunding_assistant), flag-gated. - First-match explicit-priority rules; manual-wins; queued evaluation; new-leads-only by default.
- Everee token as Convex env var (single MFM tenant) referenced by config row; hosted onboarding (never collect PII).
InvoicePaid.amountPaidas the sole v1 commission basis event.- Rule/plan versioning via snapshots + same-table versions (no separate version tables).
Product decisions Brock must approve
- Phase 0 enforcement posture: hard-block unverified location bindings vs grandfather existing rows after audit.
- Role taxonomy: are assistant/advisor/manager/partner/employee/contractor the right seed set, and which role mirrors to GHL
assignedTo? - 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.
- Approval workflow depth: single admin approval + SoD rule (proposed) vs two-person approval for payouts above a threshold.
- Whether enabling CAM sync in prod (scope + flags) is in-scope for this initiative or stays a separate decision.
- Replacing the manual Gusto SOP (
docs/SOP-Paying-Commissions.md) — timing and comms. - Portal earnings will change from empty/legacy to real numbers at Phase 6 — partner communication plan.
Provider questions requiring Everee confirmation
- Sandbox: how obtained, base URL, key format, feature parity [unknown].
- Error response schema and documented retry guidance [unknown].
- Post-payout corrections: any void/reversal API, or portal/support-only? Exact
payment.deposit-returnedpayload [unknown]. - Can portal-side payment approval be disabled/automated for API-submitted payables, or is it mandatory (affects payout latency)? [unknown]
- Confirm US-only; any state restrictions for our contractor mix [unknown].
- White-label/Flex Build commercial terms if we ever run payouts per-customer-EIN [unknown].
- Webhook signing-key rotation procedure and sandbox webhook support [unknown].
Technical questions that can be deferred
- Encrypted-at-write token table (needed only if provider keys multiply beyond env vars).
- Effective-dated routing rules; full rule version browser.
- FUND-1921 sub-partner override chains atop
commissionEntries. - Unified audit-log browser across domains.
- Whether
processingTeam/autoAssignshould migrate ontopayees/roleAssignments(parallel systems are acceptable initially).
Risks that could materially change the architecture
- GHL marketplace scope changes requiring re-consent: if adding
users.readonly(and optionallyaffiliate-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]. - 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].
- No sandbox access in reasonable time: Phases 7–8 would need a stub-provider adapter to keep momentum (the adapter boundary already supports this).
- Multi-EIN reality: if payouts must run under multiple legal entities, config becomes per-instance (token, tenant, webhooks ×N) — the schema anticipates it (
payoutProviderConfigper provider row; accounts carryevereeTenantId) but settings UX would grow. - 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.

