Partner-Commission Topology — Options A & B (design of record)
Authored against
origin/main(414439c) + the open PR #68 branchphase-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:
- Option A — partner as PAYEE. Redpoint is merchant of record; the buyer pays
into Redpoint's Stripe; the editor is paid by a Connect transfer; the partner is
paid by a SECOND transfer.
platform_fee_bpsis Redpoint's retained cut. Fits seat tiers 1–2 (hosted / hybrid). Built dry-run on PR #68. - Option B — partner as MERCHANT (white-label). The buyer pays the PARTNER via
Stripe Connect destination charges (
on_behalf_of+transfer_data.destination); Redpoint's cut is the charge'sapplication_fee_amountwhich MAY be 0; the partner is seller and VAT-of-record. PLUS a recurring MONTHLY SEAT FEE billed as a separate Stripe Billing subscription on the partner — independent of transaction volume, paid even at zero bookings. Fits tier 3 (headless). To build.
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:
- Checkout —
createOrderbuilds a Stripe Checkout session withmode:"payment"andpayment_intent_data.capture_method:"manual"(authorize now, capture at client approval):src/lib/orders.ts:264-301(capture method at:279, metadataorder_id/tenant_idat:283-285). The success/cancel URL policy is injected through thecreateCheckoutSessionseam (src/lib/checkout.ts:77-88); that seam OWNSsuccess_url/cancel_urland takes everything else asparams(Omit<…,"success_url"|"cancel_url">,src/lib/checkout.ts:61) — sopayment_intent_datais caller-supplied and is exactly where Option B's destination-charge fields will ride. - Capture at approval — the
CLIENT_REVIEW→APPROVEDedge 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, taggedmetadata.kind:"editor_payout"(:275-280). - Ledger — money truth lands in
ledger_entriesONLY from verified webhooks (src/lib/ledger.ts, idempotent onunique(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 oureditor_payout-tagged transfers —webhook-money.ts:368-393),refund/transfer_reversal(webhook-money.ts:412-540). - Editor payout economics —
platform_fee_bpslives ontenants(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.tsbuildOnboardingLink, Express +transferscapability).
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)
- Migration
supabase/migrations/0035_partner_commission.sql— two admin-only tables (anon + authenticated write revoked, admin SELECT only, matching the 0005/0008 grant posture):tenant_connect— one row per tenant:stripe_account_id(the partner's own Connect account = the commission payee; NULL for first-party) andcommission_live(per-tenant arm flag, default false). Deliberately a SEPARATE table fromtenantsbecausetenantscarries a blanketgrant select … to anon(0005) and RLS filters rows not columns, so a payee pointer ontenantswould be anon-readable. Backfills one row per existing tenant.partner_commissions— the accrual ledger, one row per job (unique(job_id)): component breakdown (gross_minor,editor_payout_minor,platform_fee_minor,stripe_fee_minor), the netamount,platform_fee_bpsused,destination,stripe_transfer_id,status ∈ {accrued, sent, failed}, and a deterministicattemptcounter — mirroringpayoutsexactly.
src/lib/partner-commission.ts— the two dangerous pieces:computePartnerCommission— the pure split math (integer minor units, currency-agnostic, clamps negative → 0 and flagsclamped).estimatePartnerStripeFeeMinor— config-first Stripe-fee ESTIMATE at accrual (billing_config.stripe_fee_bps/stripe_fee_fixed_minor, defaulting toDEFAULT_FEE_MODELinsrc/lib/margin.ts:29-37). The ACTUAL fee only settles on the balance transaction later.accruePartnerCommission— records the row and, ONLY when armed, fires the Connect transfer. Never throws (ruling 6); exactly-once viaunique(job_id)backstop; deterministic idempotency keypartner_commission_{job}_a{attempt}; transfer taggedmetadata.kind:"partner_commission"(distinct fromeditor_payout).
- Commission leg wired into the transition —
src/lib/transitions.ts(PR #68), strictly AFTER the editor payout and only on a winningAPPROVEDcapture, wrapped so a leg failure can never roll back the capture/payout/auto-delivery. It readstenant_connect.stripe_account_id/commission_live,job.price_snapshotas gross, andtenants.platform_fee_bps/billing_config; a pre-0035 DB (table absent → null) simply disables the leg. - Partner Connect onboarding —
buildTenantOnboardingLink(src/lib/connect.ts, PR #68): a SIBLING of the editor builder (the editor money path stays byte-identical), same atomic settle-on-one-account claim; Express account,transferscapability,metadata.tenant_id. Admin-only routesrc/app/api/connect/tenant-onboard/route.ts. - Ops review —
src/app/ops/finance/commissions/page.tsx.
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)
- Onboard the partner as a Connect payee (admin
POST /api/connect/tenant-onboard→ complete the hosted Express onboarding). - Set the partner tenant's
platform_fee_bpsto the agreed split (see blocker 3). - Review accrued
partner_commissionsrows against expectation for a real window. - Set
PARTNER_COMMISSION_ENABLED=1(env) ANDtenant_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
- 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_pnlFX residual; must be resolved before arming a EUR partner. - Finance-ledger write is MISSING.
accruePartnerCommissionwrites onlypartner_commissions+events— it does NOT callrecordLedger. AndhandleTransferCreatedrecords ONLYmetadata.kind === "editor_payout"(src/lib/webhook-money.ts:378), so a firedpartner_commissiontransfer never reachesledger_entries/ job P&L. Before arming, add a ledger leg (either a newpartner_commissionledger type recorded from thetransfer.createdhandler whenkind === "partner_commission", or a directrecordLedgercall) so commission outflow appears in finance reporting. platform_fee_bpsdefaults to 0. With the default,platform_fee_minor = 0and 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.- Split SEMANTIC is ASSUMED. The
− stripe_feeterm (partner absorbs processing) is the code's assumption, flagged for operator confirmation; the alternative (Redpoint eats the Stripe fee) isstripeFeeMinor = 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):
- A refund on a destination charge does NOT automatically reverse the destination
transfer or refund the application fee. To fully unwind, a full refund needs
reverse_transfer:true(claw the partner's share back) and a decision onrefund_application_fee(does Redpoint return its cut?), PLUS the existing editor clawback (auto_reverse_payout) because the editor was funded from the application fee. Partial refunds: policy call (probably ops-decided, as today). - Who funds the refund: on a destination charge the refund debits the platform by
default;
reverse_transferpulls the partner's portion back. Exact flow must be confirmed against Stripe docs and the accountant. - The ledger's
stripe_feecurrency note (webhook-money.ts:76-92) already handles settlement-currency fees; underon_behalf_ofthe fee is the PARTNER's, so the engine-sidestripe_feeledger row semantics change (it may no longer be Redpoint's cost) — reconcile.
(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:
- A Stripe Customer on Redpoint's platform representing the partner as a payer, with a saved payment method. (Note: the partner's Connect account is a payEE; the seat fee needs the partner as a payER — two separate objects for the same partner.)
- A recurring Price per seat tier (the monthly fee; GBP primary).
- Create a Subscription on onboard; cancel on offboard; handle dunning via
Stripe smart retries + the
invoice.payment_failedwebhook → after N failures suspend the seat (aseat_statusontenant_connect, ortenants.active=false). - Paid even at zero bookings — it is volume-independent by design.
- Store
seat_customer_id,seat_subscription_id,seat_statusontenant_connect(sketch in §3). This slice can be built in parallel with the transaction re-plumb.
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):
- The split math
computePartnerCommission— A pays the residual out; B sets it as the application fee. Same numbers. - The accrual ledger
partner_commissions— records the split for operator review in BOTH topologies (under B,amount= Redpoint commission; the money moved natively, so a newstatusvalue e.g.native/settleddistinguishes it from A's firedsent/accrued). - Connect onboarding via
buildTenantOnboardingLink— extended with a capability set keyed on topology (transfers-only for A, +card_payments for B). - The two-gate arming safety model (
commission_live+ env) — carries over.
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)
- Split VALUE. The per-tenant commission — as
platform_fee_bps(reused) or a dedicated bps — that becomesapplication_fee_amount = editor_fare + commission. May be 0. Confirm the same− stripe_feesemantic question as Option A (does the partner absorb processing?), which underon_behalf_ofis partly forced (the fee is the partner's). - 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?).
- 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. - 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, viaon_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.
- 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.
- Topology flag migration (§3) — additive, inert at
payee. - Heavier Connect onboarding for merchant tenants —
card_paymentscapability + full KYC. Do this early: it is an EXTERNAL dependency (partner must complete KYC) and gates any live destination charge. - Checkout re-plumb — destination-charge fields in
orders.ts/payment_intent_data, strictly flag-gated sopayee/first-party tenants stay byte-identical. Highest blast radius: this touches the charge creation for ALL one-off orders. - Editor-payout-under-B + refund/dispute rework — application fee funds the editor
(mechanics unchanged); rework
webhook-money.tsrefund/dispute handling forreverse_transfer/refund_application_fee. Money-movement correctness under a new flow. - Monthly seat subscription lifecycle — separate Stripe Customer + Billing subscription; can be built in PARALLEL with 2–4.
Biggest risks (in order):
- Checkout re-plumb — it modifies the money spine's charge creation for every
one-off order. Must be flag-gated and proven byte-identical for non-merchant tenants
(sentinel-binding e2e: a
payee/first-party order produces the identical PI shape as today; amerchantorder produces a destination charge with the rightapplication_fee_amount). - Per-partner KYC — full
card_paymentsKYC is an external, human, per-partner dependency that can block go-live independently of the code. - Editor-payout-under-B + refunds — correctness of the application-fee-funds-editor
model and the refund/dispute unwind (
reverse_transfer+ editor clawback) is the place a real double-pay or unfunded-refund bug could hide. Falsification tests required.
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 |
A — tenant_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 tomain. Migration numbering landed at 0035 (not the roadmap's projected 0034 — 0034 was claimed by an in-flight voice slice; see the 0035 header).