> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Fund 1921 subpartner commissions

# 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`](../../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`](../../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`

```ts theme={null}
// 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:

```ts theme={null}
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.locationId` →
  `partnerOrganizations.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`](../../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

```mermaid theme={null}
flowchart TD
  subgraph Hierarchy
    P["Parent org\n(partnerOrganizations)"]
    S1["Sub-partner A\nparentOrgId = P"]
    S2["Sub-partner B\nparentOrgId = P"]
    G1["Sub-sub-partner\nparentOrgId = S1"]
    P --> S1
    P --> S2
    S1 --> G1
  end

  D["Funded deal (Zoho)"] --> BC["salesCommissions\n(selling org = S1)"]
  BC --> RU["recomputeOverridesForCommission\n(idempotent, by_source)"]
  RU -->|level 1| OV1["overrideCommissions\nbeneficiary = P"]
  RU -.->|"level 2 (if from G1)"| OV2["overrideCommissions\nbeneficiary = P (via S1)"]

  BC --> PE["partnerEarnings: direct"]
  OV1 --> PE2["partnerEarnings: override"]
  PE --> UI["Partner portal: earnings + downline roster"]
  PE2 --> UI
```

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