Skip to main content

FUND-1921 — Sub-partner / multi-level commission tracking (override chains)

Status: design only — no implementation in this pass. Internal design note; not part of the Mintlify docs site (docs/docs.json). Source: Slack 2026-05-08 (New Day For You Financial / Miranda). Related: FUND-1152.

Problem & goal

Today a referral partner earns a commission on deals they personally source. We want to support multi-level partners: a partner (“parent”) recruits other partners (“sub-partners”), and when a sub-partner’s deal funds, the parent earns an override commission on top of the sub-partner’s own commission. This can extend more than one level (an “override chain”). Concretely we need to:
  1. Model a parent → sub-partner hierarchy between partner organizations.
  2. On a funded deal, attribute the base commission to the selling partner and compute override commissions up the chain.
  3. Show each partner their own earnings plus downline/override earnings, and a roster of their sub-partners, in the partner portal.

Current state (grounded in code)

  • partnerOrganizations (convex/schema.ts ~L2743): clerkOrgId, locationId, name, slug, plan, brandingConfig. There is no parent/referrer field — the model is flat and single-level.
  • salesCommissions (~L1475): one row per setter per deal event (setterId, zohoOrgId, locationId, eventType, salesStatus, commissionAmount, commissionMode flat/percentage, dealAmount, pay-period window, isPaid). Indexed by_setter, by_location, by_org, by_pay_period.
  • closerCommissions (~L1512): same shape for closers (“sold” only).
  • partnerEarnings.ts: getAuthorizedPartnerOrganization resolves the Clerk org → partnerOrganizations row → locationId, then listLocationCommissions + summarizeLocationCommissions aggregate a single location’s salesCommissions. A partner sees only their own location today.
  • Referral attribution already exists: partnerReferralLinks (Dub links per clerkOrgId) and leadEvents.referralOrgId / dubId (convex/schema.ts ~L2727, ~L2801). A lead can already carry the org that referred it — the natural hook for chain attribution.
So the building blocks (per-partner orgs, per-deal commission rows, referral attribution) exist; what’s missing is the hierarchy edge and an override roll-up.

Proposed schema (design)

1. Hierarchy edge on partnerOrganizations

// additive, optional → backward compatible
parentOrgId: v.optional(v.id("partnerOrganizations")),   // direct upline
// denormalized ancestor path (root → ... → direct parent) for O(1) roll-up
// reads without recursive queries; capped depth (see open questions).
ancestorOrgIds: v.optional(v.array(v.id("partnerOrganizations"))),
overrideConfig: v.optional(v.object({
  mode: v.union(v.literal("flat"), v.literal("percentage")),
  // ratesByLevel[i] = override earned on downline depth i+1
  ratesByLevel: v.array(
    v.object({
      pct: v.optional(v.number()),
      flat: v.optional(v.number()),
    }),
  ),
})),
// Write-time validation: when mode === "percentage", each ratesByLevel[i].pct
// must be set; when mode === "flat", each ratesByLevel[i].flat must be set.
Add .index("by_parent", ["parentOrgId"]) for roster/downline queries. Hierarchy maintenance: ancestorOrgIds is denormalized for O(1) roll-up reads. When parentOrgId changes, every descendant’s ancestorOrgIds must be recomputed — a fan-out proportional to subtree size. Convex mutations stay bounded, so maintenance runs as chained internal mutations scheduled in batches (e.g. .take(50) descendants per hop via by_parent BFS) until the subtree is drained. A strict depth cap (see open questions) also caps worst-case write fan-out. Parent-link writes reject cycles (see open question 8) before any cascade starts.

2. Override commission records

Mirror salesCommissions rather than overload it, so base vs override stay auditable and payable independently:
overrideCommissions: defineTable({
  // who earns the override
  beneficiaryOrgId: v.id("partnerOrganizations"),
  // the funded deal + the selling org that generated it
  sourceCommissionId: v.id("salesCommissions"),
  sellingOrgId: v.id("partnerOrganizations"),
  level: v.number(),               // 1 = direct sub-partner, 2 = grand-, ...
  commissionAmount: v.number(),
  commissionMode: v.union(v.literal("flat"), v.literal("percentage")),
  basisAmount: v.optional(v.number()),  // amount the % applied to
  payPeriodStart: v.number(),
  payPeriodEnd: v.number(),
  isPaid: v.boolean(),
  paidAt: v.optional(v.number()),
  createdAt: v.number(),
})
  .index("by_beneficiary", ["beneficiaryOrgId"])
  .index("by_beneficiary_period", ["beneficiaryOrgId", "payPeriodStart"])
  .index("by_source", ["sourceCommissionId"])   // idempotency / recompute
  .index("by_pay_period", ["payPeriodStart", "isPaid"]),
Keying overrides on sourceCommissionId makes the roll-up idempotent: a recompute replaces unpaid override rows for a given source rather than appending duplicates (the FUND-1918 duplication lesson).

Attribution model

funded deal → base salesCommission (selling org)
            → walk ancestorOrgIds (or parentOrgId chain)
            → for each ancestor at level N with an overrideConfig:
                 cfg = ancestor.overrideConfig
                 rate = cfg.ratesByLevel[N - 1]
                 if rate is missing OR (cfg.mode === "percentage" ? rate.pct == null : rate.flat == null):
                   log warning; skip this ancestor (never write NaN)
                 amount = cfg.mode === "percentage"
                   ? round(basis * rate.pct)
                   : rate.flat
                 upsert overrideCommissions[(source, beneficiary)]
  • Basis: override percentage applies to the deal amount (or to the base commission — open question). Flat mode pays a fixed per-deal override.
  • Selling org resolution: from salesCommissions.locationIdpartnerOrganizations.by_location; the selling org’s ancestorOrgIds give the chain in one read. locationId is optional on salesCommissions today — if it is absent at recompute time, log an audit row and skip override generation (no silent no-op). New Zoho ingestion should require locationId before persisting a funded commission row so overrides stay attributable.
  • Depth cap: bound the walk (e.g. 2–3 levels) to keep payouts and reads predictable.

Roll-up engine

  • Trigger: whenever a salesCommissions row is created/transitions to the funded event (reuse the existing Zoho → salesCommissions ingestion seam), schedule an internal mutation recomputeOverridesForCommission(sourceCommissionId). closerCommissions is out of scope for v1 — overrides roll up from setter (sales) commissions only; see “Explicitly out of scope”.
  • Idempotent: for a given source, delete only unpaid overrideCommissions.by_source rows, then upsert per current chain + config. If any by_source row has isPaid: true, abort recompute and surface for manual review — never delete paid overrides (preserves payout audit trail and avoids duplicate 1099 risk). Safe to replay/backfill on unpaid sources only.
  • Pay-period aligned: copy payPeriodStart/End from the source commission so override payouts ride the same payroll cycle.
  • Bounded scans only (.take(N), indexed lookups) per the repo’s Convex conventions — no unbounded .collect().

Portal UI (design)

  • Earnings: extend partnerEarnings.getEarningsSummary to return { direct, override, total }. Add listOverrideCommissions(beneficiaryOrgId) for the override history table. Reuse the existing earnings page shell (app/partner-portal/earnings/page.tsx).
  • Downline: a “My partners” roster from partnerOrganizations.by_parent, with each sub-partner’s funded count and the override earned from them.
  • Plan gating: multi-level likely gates on plan: "premium" (open question).
  • Auth stays org-scoped via getAuthorizedPartnerOrganization; a parent may only read aggregate downline numbers, not a sub-partner’s raw client data.

Open questions

  1. Rate ownership: who sets override %s — FM admin globally, or per-parent? Per-level rates or a single rate? Flat vs percentage default?
  2. Basis: override on deal amount or on the sub-partner’s commission?
  3. Depth: how many levels do we pay (cap)? Does it vary by plan?
  4. Source of truth: commissions originate in Zoho today; is the hierarchy defined in Zoho, Clerk orgs, or FM admin? Where is the parent link authored?
  5. Recruitment linkage: do we set parentOrgId from the Dub referralOrgId/partnerReferralLinks at sub-partner signup, or via an admin assignment UI?
  6. Retroactivity: when a parent link changes, do past funded deals recompute, or only deals after the change? Paid override rows are immutable — retroactive recompute is blocked once any by_source row is paid.
  7. Payout/compliance: tax/1099 implications of override income; clawback on refunds/chargebacks (mirror isPaid reversal).
  8. Self-referral / cycle guards: prevent A→B→A loops; enforce on write.

Hierarchy & data flow

Explicitly out of scope (this pass)

No schema migration, mutations, queries, or UI are implemented here. Override roll-up for closerCommissions is also deferred — v1 targets setter-side salesCommissions only; extending to closers would require a polymorphic source ref on overrideCommissions (union id or separate sourceCloserCommissionId). This note defines the model so implementation can be scoped into its own phase/PR once the open questions above are answered.