☰ MenuCommission Topology

Partner-Commission Topology — Options A & B (design of record)

Authored against origin/main (414439c) + the open PR #68 branch phase-11-partner-commission. This is a DESIGN doc — no product code changes. It exists to make BOTH partner-commission topologies planned up front, so a single per-tenant switch can select either without re-deciding the money spine.

Operator ruling this encodes

Both topologies must be possible, selectable per tenant:

Seat tiers (from the front-end-seats direction): tier 1 hosted, tier 2 hybrid, tier 3 headless. The topology tracks the tier but is not locked to it — the switch is per tenant.


0. The shared money spine (both options inherit this)

The one-off order path is the only charged flow (retainer/credit packages debit an entitlement and mint directly, no Stripe charge — src/lib/orders.ts:303-394). The charged flow today:

  1. CheckoutcreateOrder builds a Stripe Checkout session with mode:"payment" and payment_intent_data.capture_method:"manual" (authorize now, capture at client approval): src/lib/orders.ts:264-301 (capture method at :279, metadata order_id/tenant_id at :283-285). The success/cancel URL policy is injected through the createCheckoutSession seam (src/lib/checkout.ts:77-88); that seam OWNS success_url/cancel_url and takes everything else as params (Omit<…,"success_url"|"cancel_url">, src/lib/checkout.ts:61) — so payment_intent_data is caller-supplied and is exactly where Option B's destination-charge fields will ride.
  2. Capture at approval — the CLIENT_REVIEW→APPROVED edge claims the transition atomically, then captures the authorized PaymentIntent and pays the editor, in that order, inside one serialization point: src/lib/transitions.ts:638-718. capturePayment (src/lib/payouts.ts:114-141) captures; attemptPayout (src/lib/payouts.ts:187-312) transfers the editor fare to the editor's Connect account, tagged metadata.kind:"editor_payout" (:275-280).
  3. Ledger — money truth lands in ledger_entries ONLY from verified webhooks (src/lib/ledger.ts, idempotent on unique(stripe_ref,type); src/lib/webhook-money.ts): capture (+), stripe_fee (−, in the balance-transaction/settlement currency — webhook-money.ts:76-92), editor_transfer (−, only for our editor_payout-tagged transfers — webhook-money.ts:368-393), refund/transfer_reversal (webhook-money.ts:412-540).
  4. Editor payout economicsplatform_fee_bps lives on tenants (default 0, supabase/migrations/0001_init.sql:32) and is Redpoint's fee for licensed tenants. Editors are PLATFORM-level accounts (profiles.stripe_account_id, 0001_init.sql:60), onboarded onto Redpoint's ONE Connect platform (src/lib/connect.ts buildOnboardingLink, Express + transfers capability).

Invariant both options must preserve: first-party tenants (Wakecut, Edits) are BYTE-IDENTICAL to today — no partner account, no commission, no destination charge, no subscription. Every new mechanism is inert unless a tenant is explicitly a partner with a topology set.


1. Option A — partner as payee (BUILT, dry-run, PR #68)

What it is

Separate-charges-and-transfers, unchanged spine. The buyer still pays into Redpoint's Stripe (manual capture); the editor is still paid by a Connect transfer. On top, the partner that brought the buyer earns a commission paid by a SECOND Connect transfer:

platform_fee      = round(gross × platform_fee_bps / 10000)          [Redpoint's cut]
partner_commission = gross − editor_payout − platform_fee − stripe_fee   (clamped ≥ 0)

Redpoint is merchant of record and VAT-of-record; the partner receives a net transfer. First-party tenants (no partner Connect account) always yield commission 0 and nothing accrues.

What is built (all on the OPEN PR #68 branch phase-11-partner-commission)

DRY-RUN-FIRST (the safety model, mirrors retention 0030)

Commission is ACCRUED (recorded, reviewable) on every partner-tenant capture, but a real transfer fires ONLY when BOTH gates are set: per-tenant commission_live AND env PARTNER_COMMISSION_ENABLED=1. Both default OFF, so on merge the leg records numbers and moves ZERO money.

Arming procedure (operator, once the numbers are validated)

  1. Onboard the partner as a Connect payee (admin POST /api/connect/tenant-onboard → complete the hosted Express onboarding).
  2. Set the partner tenant's platform_fee_bps to the agreed split (see blocker 3).
  3. Review accrued partner_commissions rows against expectation for a real window.
  4. Set PARTNER_COMMISSION_ENABLED=1 (env) AND tenant_connect.commission_live=true (per tenant). The next captured job fires the real transfer.

Arming blockers (must clear BEFORE flipping the gates) — verified against code

  1. EUR FX. The accrual is currency-agnostic and labels the row with the GROSS currency, but the editor fare netted out is platform-GBP. A EUR gross against a GBP editor fare is an unresolved FX conversion — a real EUR transfer needs a rate. Documented as the deferred job_pnl FX residual; must be resolved before arming a EUR partner.
  2. Finance-ledger write is MISSING. accruePartnerCommission writes only partner_commissions + events — it does NOT call recordLedger. And handleTransferCreated records ONLY metadata.kind === "editor_payout" (src/lib/webhook-money.ts:378), so a fired partner_commission transfer never reaches ledger_entries / job P&L. Before arming, add a ledger leg (either a new partner_commission ledger type recorded from the transfer.created handler when kind === "partner_commission", or a direct recordLedger call) so commission outflow appears in finance reporting.
  3. platform_fee_bps defaults to 0. With the default, platform_fee_minor = 0 and the partner receives gross − editor − stripe_fee (Redpoint keeps nothing on the transaction). The operator must set a real per-partner bps before arming, or accept a 0% transactional cut by intent.
  4. Split SEMANTIC is ASSUMED. The − stripe_fee term (partner absorbs processing) is the code's assumption, flagged for operator confirmation; the alternative (Redpoint eats the Stripe fee) is stripeFeeMinor = 0. Confirm before arming.

Minor residual (doc drift, not a bug)

partner-commission.ts doc-comments still refer to tenants.stripe_account_id / tenants.commission_live, but after PR #68's relocation commit the actual reads are against tenant_connect (in transitions.ts). Comment-only staleness; worth a one-line fix when the PR is next touched.


2. Option B — partner as merchant / white-label (TO BUILD)

What it is

The buyer pays the PARTNER. The charge is created on Redpoint's platform as a Stripe Connect destination charge: on_behalf_of = partner + transfer_data.destination = partner, with application_fee_amount = Redpoint's cut. The partner is the seller, merchant of record, and VAT-of-record; on_behalf_of is what shifts settlement currency, Stripe-fee attribution, and DISPUTE liability to the partner. Redpoint's transactional revenue is the application fee, which may be 0. On top of (and independent of) the transaction flow, Redpoint bills the partner a recurring monthly seat fee as a Stripe Billing subscription — paid even at zero bookings.

The economic identity that makes B cheap to build

Under a destination charge, at capture:

partner receives (native, automatic):  gross − application_fee_amount
Redpoint retains (platform balance):    application_fee_amount

Set application_fee_amount = editor_fare + redpoint_commission. Then Redpoint keeps the application fee in its OWN platform balance and pays the editor from it via the EXISTING attemptPayout transfer — byte-identical editor mechanics. Redpoint's net on the transaction = application_fee − editor_fare = redpoint_commission (which may be 0, i.e. application_fee_amount = editor_fare — Redpoint is a pass-through funding the editor, and earns only the monthly seat fee). And:

partner_net = gross − editor_fare − redpoint_commission (− stripe_fee if partner bears it)

This is the SAME split as Option A — only the DIRECTION of the residual flips (in A the partner is paid the leftover by a transfer; in B the partner keeps it natively and Redpoint's application fee funds editor + commission). computePartnerCommission is reusable verbatim: application_fee_amount = breakdown.editorPayoutMinor + breakdown.commissionMinor (+ optional Stripe-fee buffer if the platform bears the fee).

Concretely, what changes

(a) Checkout charge type — src/lib/orders.ts / src/lib/checkout.ts. In createOrder's one-off branch (orders.ts:264-301), when the tenant's topology is merchant, add to payment_intent_data:

payment_intent_data: {
  capture_method: "manual",              // unchanged — authorize now, capture at approval
  on_behalf_of: partnerConnectAccountId, // merchant of record + dispute/VAT/fee shift
  transfer_data: { destination: partnerConnectAccountId },
  application_fee_amount: editorFare + redpointCommission,
  metadata: { order_id, tenant_id },
}

These ride through the existing createCheckoutSession seam untouched — the seam only owns success_url/cancel_url and passes payment_intent_data through (checkout.ts:61,77-88). Manual capture is compatible with destination charges: the destination transfer to the partner fires AT CAPTURE (client approval), aligning with the existing capture-at-approval model, and application_fee_amount can be finalised before capture. The payee (Option A) path leaves payment_intent_data exactly as today.

(b) Per-tenant topology flag. Add topology ∈ {payee, merchant} to tenant_connect (migration sketch in §3). payee = Option A / today; merchant = Option B. The flag is read once in the checkout path to choose the charge shape and in the transition/webhook paths to choose settlement handling.

(c) application_fee_amount is set at checkout, not at approval. Unlike Option A (commission computed at capture), B must decide the application fee when the PI is CREATED (it is a PI field). Compute it in createOrder from the frozen fare + per-tenant commission bps, using computePartnerCommission inputs. (It can be updated pre-capture if the fare changes, but the frozen fare makes that unnecessary.)

(d) Connect account TYPE + KYC — materially heavier than A. Option A's payee needs only the transfers capability (a recipient account — receive-only, minimal KYC). Option B needs the partner account to be a chargeable settlement merchant under on_behalf_of: it must have card_payments + transfers capabilities and complete FULL KYC (identity, business details, bank account, and per-country requirements) before any destination charge can name it. This is per-partner onboarding friction and an external dependency that can block go-live. buildTenantOnboardingLink must request the card_payments capability (and likely a non-Express account type, e.g. Standard/Custom, depending on who owns the onboarding UX and dashboard) for merchant-topology tenants.

(e) How the editor still gets paid under B — RECOMMENDATION. Fund the editor from Redpoint's application fee (option iii above): application_fee_amount includes the editor fare, and Redpoint's existing attemptPayout transfers the fare to the editor from the platform balance, exactly as today. This keeps the highest-care code (payouts.ts) unchanged. Reject the alternative of the partner paying the editor directly: editors are onboarded onto REDPOINT's Connect platform, not the partner's, so a partner→editor transfer is not possible in this Connect topology without onboarding every editor onto every partner — a non-starter. Also reject "Redpoint fronts the editor from general funds" — it creates an unfunded liability if the charge later fails/refunds.

(f) Refunds / disputes move to the partner. With on_behalf_of, dispute liability (and the dispute fee) sits with the partner by default — the operator's intent. Refund handling must change (src/lib/webhook-money.ts:412-540 today reverses only the EDITOR transfer under auto_reverse_payout):

(g) Monthly seat-fee subscription lifecycle (B-only, INDEPENDENT of transactions). This bills the partner AS A PAYER — the OPPOSITE direction from the Connect payout. It needs Stripe objects distinct from the partner's Connect account:


3. Coexistence — A and B behind one per-tenant flag

One switch. tenant_connect.topology ∈ {payee, merchant} selects A vs B per tenant. Default payee keeps every existing and first-party tenant on today's spine. The flag is consulted in exactly three places: the checkout charge builder (orders.ts), the commission/settlement handling at capture (transitions.ts), and the refund/dispute webhook path (webhook-money.ts).

Concern Option A (payee) Option B (merchant)
Merchant of record Redpoint Partner (on_behalf_of)
VAT-of-record Redpoint Partner
Buyer pays into Redpoint's Stripe Redpoint platform, settling to partner
Charge type plain manual-capture PI destination charge (manual capture)
Redpoint's cut platform_fee_bps retained; partner paid the leftover by a 2nd transfer application_fee_amount (may be 0)
Editor paid by Redpoint transfer (as today) Redpoint transfer, funded by application fee (as today)
Partner paid by 2nd Connect transfer (armed) native destination transfer (automatic at capture)
Dispute liability Redpoint Partner
Connect account Express, transfers only, light KYC card_payments+transfers, FULL KYC
Recurring seat fee Stripe Billing subscription (monthly, volume-independent)

Shared (build once, both use):

Divergent (topology-branched): the checkout charge type, merchant/VAT-of-record, dispute/refund handling, and the monthly subscription (B-only).

Migration sketch (topology flag + seat-fee columns on tenant_connect)

Purely additive to the 0035 table; no change to A's behaviour when topology='payee'.

alter table public.tenant_connect
  add column topology text not null default 'payee'
    check (topology in ('payee','merchant')),
  -- B-only: the partner as a PAYER of the monthly seat fee (distinct from the
  -- payEE Connect account already on this table).
  add column seat_customer_id text,       -- Stripe Customer (partner pays Redpoint)
  add column seat_subscription_id text,   -- Stripe Billing subscription id
  add column seat_status text not null default 'inactive'
    check (seat_status in ('inactive','active','past_due','canceled'));

And partner_commissions.status gains a value for a natively-settled (B) row, e.g. check (status in ('accrued','sent','failed','native')), so a B row is auditable alongside A rows without a fired transfer.


4. Decision points that gate the B build (operator + accountant)

  1. Split VALUE. The per-tenant commission — as platform_fee_bps (reused) or a dedicated bps — that becomes application_fee_amount = editor_fare + commission. May be 0. Confirm the same − stripe_fee semantic question as Option A (does the partner absorb processing?), which under on_behalf_of is partly forced (the fee is the partner's).
  2. Monthly seat-fee amount + billing model. Per-tier price, currency (GBP vs the partner's currency), proration, trial, and the dunning/suspension policy (after how many failed invoices does the seat suspend, and what does suspension DO — block new orders? freeze delivery?).
  3. VAT / merchant-of-record confirmation. Confirm on_behalf_of = partner gives the intended VAT treatment (partner is seller/VAT-of-record), and how Redpoint's application fee AND the monthly seat fee are invoiced to the partner for UK VAT (B2B service fee / reverse charge). This is an accountant sign-off, not a code decision.
  4. Editor payout + refund/dispute liability under B. Confirm the recommendation (Redpoint funds the editor from the application fee; editor mechanics unchanged), and the refund flow (reverse_transfer, refund_application_fee, editor clawback) and who carries chargebacks (partner, via on_behalf_of). Confirm against current Stripe destination-charge docs before implementing.

5. Recommended build order + biggest risks

Order. B is a FOLLOW-ON slice, after Option A is armed on a real partner.

  1. Merge + arm Option A first (PR #68). Clear the A arming blockers (§1) — the finance-ledger write especially — and prove the split math + accrual ledger with real money in the SIMPLE topology. A is the low-risk proving ground for the shared math.
  2. Topology flag migration (§3) — additive, inert at payee.
  3. Heavier Connect onboarding for merchant tenantscard_payments capability + full KYC. Do this early: it is an EXTERNAL dependency (partner must complete KYC) and gates any live destination charge.
  4. Checkout re-plumb — destination-charge fields in orders.ts/payment_intent_data, strictly flag-gated so payee/first-party tenants stay byte-identical. Highest blast radius: this touches the charge creation for ALL one-off orders.
  5. Editor-payout-under-B + refund/dispute rework — application fee funds the editor (mechanics unchanged); rework webhook-money.ts refund/dispute handling for reverse_transfer/refund_application_fee. Money-movement correctness under a new flow.
  6. Monthly seat subscription lifecycle — separate Stripe Customer + Billing subscription; can be built in PARALLEL with 2–4.

Biggest risks (in order):


Appendix — grounding (file:line)

Claim Source
One-off Checkout, manual capture, metadata src/lib/orders.ts:264-301 (capture :279)
Checkout seam owns success/cancel, passes params through src/lib/checkout.ts:61,77-88
Capture-then-payout serialization point src/lib/transitions.ts:638-718
capturePayment src/lib/payouts.ts:114-141
Editor payout transfer, kind:"editor_payout" src/lib/payouts.ts:187-312 (:275-280)
Ledger fed only from webhooks, idempotent src/lib/ledger.ts:29-46
stripe_fee in settlement currency src/lib/webhook-money.ts:76-92
handleTransferCreated records ONLY editor_payout (A ledger blocker) src/lib/webhook-money.ts:378
Refund reverses only editor transfer (auto_reverse_payout) src/lib/webhook-money.ts:412-540
platform_fee_bps default 0, "Redpoint fee for licensed tenants" supabase/migrations/0001_init.sql:32
Editors are platform-level Connect accounts supabase/migrations/0001_init.sql:60; src/lib/connect.ts buildOnboardingLink
Atenant_connect + partner_commissions tables supabase/migrations/0035_partner_commission.sql (PR #68)
A — split math + accrual mechanics src/lib/partner-commission.ts (PR #68)
A — commission leg wired at APPROVED src/lib/transitions.ts (PR #68 diff)
A — partner Connect onboarding (Express, transfers) src/lib/connect.ts buildTenantOnboardingLink + src/app/api/connect/tenant-onboard/route.ts (PR #68)
A — ops review page src/app/ops/finance/commissions/page.tsx (PR #68)
Default fee model (Stripe-fee estimate) src/lib/margin.ts:29-37
Topology decision framed as gating P11 docs/ROADMAP-PHASE-10-PLUS.md:84

Option A code is on the open PR #68 branch phase-11-partner-commission, not yet merged to main. Migration numbering landed at 0035 (not the roadmap's projected 0034 — 0034 was claimed by an in-flight voice slice; see the 0035 header).