☰ MenuOption B

Option B (white-label / merchant topology) — BUILD PLAN + decisions checklist

Companion to docs/COMMISSION-TOPOLOGY.md (the A/B design of record). That doc decides what Option B is; this doc turns it into dispatchable build packets and the one-pass operator/accountant checklist that gates each one. DESIGN/PLANNING only — no product code here.

Grounded against origin/main at e67de7c (Phase-10 slice 10a-2). Option A (PR #68, phase-11-partner-commission) is MERGED into this line (commit a413072), so every Option-A artifact this plan builds on — tenant_connect / partner_commissions (0035), src/lib/partner-commission.ts, the commission leg in src/lib/transitions.ts, buildTenantOnboardingLink (src/lib/connect.ts), the ops review page — exists in the tree today (verified file:line in the Appendix). The COMMISSION-TOPOLOGY.md header still says PR #68 is "open"; that is now stale — the code is on main.

Freshness note (2026-07-17): main has since advanced to a1cbd64 and the live DB to migration 0039. Option A's arming-prep landed (PR #71, migration 0036) — it added the P&L ledger leg (the B0 blocker below), EUR needs_fx_review holds, and fee-unset surfacing, so Option A is arming-ready pending only the operator/accountant ruling. The decisions D1–D10 below remain unanswered. This plan's own packets are still unbuilt; 0036 is now consumed by #71, so B1's first Option-B migration is the next free number — verify against supabase/migrations/ at build time (currently 0040).


0. The one invariant that governs everything

First-party and payee (Option-A) tenants must stay BYTE-IDENTICAL to today. Every Option-B mechanism is inert unless a tenant is explicitly topology='merchant' with a KYC-complete Connect account. The topology flag defaults to payee; the seat subscription, the destination charge, the refund rework, and the VAT surfacing are all gated on it. A payee/first-party order must produce the identical PaymentIntent shape it produces now (src/lib/orders.ts:278-287) — this is the sentinel-binding e2e gate that every money-touching packet must pass (house pattern; tests/e2e/editor-landing.spec.ts is the reference for sentinel binding).


1. DECISIONS NEEDED BEFORE BUILD (operator + accountant — fill in ONE pass)

Nothing in §3's packets is buildable-to-arm until these are answered. Each packet lists which of these it consumes. Values marked (accountant) are a sign-off, not a code choice.

# Decision Needed for Default if unset Answer
D1 Redpoint's application-fee cut — reuse tenants.platform_fee_bps or a dedicated bps? Flat % or per-package? MAY be 0 (pure seat-fee model, Redpoint funds the editor and earns only the monthly seat fee). B1, B3 platform_fee_bps (=0 today) ☐ ________
D2 Application-fee formula confirmation. Confirm application_fee_amount = editor_fare + Redpoint_cut where Redpoint_cut = round(gross × bps / 10000) = breakdown.platformFeeMinor. See §2 — the COMMISSION-TOPOLOGY.md snippet says + commissionMinor, which is the partner's residual and would be wrong. B3 — (must confirm) ☐ ________
D3 Stripe-fee bearer under on_behalf_of. Under a destination charge with on_behalf_of=partner, the processing fee is deducted from the partner natively. Confirm the partner bears it (matches Option A's − stripe_fee term) and that Redpoint does not add a fee buffer to the application fee. B3, B4 partner bears (native) ☐ ________
D4 Monthly seat-fee amount + currency + interval. Per-tier price (GBP primary; EUR?), interval (monthly), trial (days / none), proration on mid-cycle onboard. B5 — (must set) ☐ ________
D5 Dunning / suspension policy. After how many failed seat invoices does the seat suspend, and what does suspension DO — block new orders (tenant.active=false), freeze delivery, or dashboard-only warning? B5 Stripe smart-retries; suspend after final failure; block new orders ☐ ________
D6 Editor-payout-under-B confirmation. Confirm the recommendation: Redpoint funds the editor from the application fee via the existing attemptPayout — editors stay on Redpoint's Connect platform, src/lib/payouts.ts unchanged. (Reject partner→editor direct transfer: editors aren't on the partner's platform.) B3 recommended model ☐ ________
D7 Refund policy under B. On a full refund of a destination charge: reverse the partner's share (reverse_transfer:true)? Return Redpoint's cut (refund_application_fee:true/false)? Claw back the editor (existing auto_reverse_payout)? Partial-refund policy (ops-decided as today)? (accountant + Stripe-doc confirm) B4 full: reverse_transfer=true, refund_application_fee=true, editor clawback per auto_reverse_payout ☐ ________
D8 Dispute/chargeback liability. Confirm on_behalf_of=partner puts dispute liability + the dispute fee on the partner (the operator's intent), and how Redpoint reconciles a lost dispute against an already-paid editor. (accountant) B4 partner bears (native to on_behalf_of) ☐ ________
D9 VAT / merchant-of-record. 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). (accountant sign-off — not a code decision.) B6 partner is MoR/VAT-of-record ☐ ________
D10 EUR under merchant topology. The editor fare (fare_snapshot) is GBP; a EUR gross means the application_fee_amount (charge-currency field) mixes a GBP fare into a EUR charge — the same FX residual flagged for Option A (job_pnl FX), and harder here because the app fee is fixed at charge time. Confirm: first merchant partner is GBP-only until FX is resolved? B3 GBP-only for first merchant partner ☐ ________

2. FLAG — a money-correctness ambiguity in the design doc (resolve at D2 before B3)

docs/COMMISSION-TOPOLOGY.md states two things that do not reconcile:

breakdown.commissionMinor is the Option-A partner residualgross − editor − platform_fee − stripe_fee (src/lib/partner-commission.ts:102-108) — i.e. the large leftover the partner keeps. It is not Redpoint's cut. Using it as the application fee would set the fee to nearly the whole gross and leave the partner with almost nothing — the inverse of the intended split.

The economic identity that makes B "the same split as A" requires:

application_fee_amount = breakdown.editorPayoutMinor + breakdown.platformFeeMinor
                       = editor_fare + round(gross × bps / 10000)
⇒ partner receives (native)        = gross − application_fee_amount
                                   = gross − editor_fare − platform_fee     (= commissionMinor, minus stripe_fee borne natively)
⇒ Redpoint net (platform balance)  = application_fee_amount − editor_fare
                                   = platform_fee                           (= Option A's retained cut)

computePartnerCommission is still reusable — it returns editorPayoutMinor, platformFeeMinor, and commissionMinor from one call. B uses editorPayoutMinor + platformFeeMinor to build the charge's application fee, and records commissionMinor as the partner's audit figure in partner_commissions. B3 must pin the + platformFeeMinor form; treat the doc's + commissionMinor as an editorial error pending D2 confirmation.


3. THE PACKETS (phased; each is a dispatchable worker packet)

Ordering follows COMMISSION-TOPOLOGY.md §5. B0 is a gate, not a code packet. B1→B4 are the transaction re-plumb (serial where money moves). B5 (subscription) parallelises with B1–B4. B6 (VAT surfacing) is light and lands last.

B0 — GATE: arm Option A on a real partner first (no code)

Prove the shared split math + accrual ledger with real money in the simple topology before re-plumbing checkout. This is COMMISSION-TOPOLOGY.md §5 step 0, and it de-risks B3/B4 because the math (computePartnerCommission) and the accrual table (partner_commissions) are then battle-tested. Clear Option A's arming blockers first — the finance-ledger write especially (accruePartnerCommission writes only partner_commissions + events, never recordLedger, and handleTransferCreated records only kind==="editor_payout" at src/lib/webhook-money.ts:378 — so a fired partner_commission transfer never reaches the ledger / job P&L). Do not start B3 until A has settled at least one armed commission.


B1 — Per-tenant topology flag + selection seam (additive, inert at payee)


B2 — Merchant Connect onboarding + KYC readiness (external dependency; do early)

Option A's payee needs only the transfers capability (receive-only, light KYC). A merchant under on_behalf_of must be a chargeable settlement account: card_payments + transfers capabilities and full KYC (identity, business, bank, per-country) before any destination charge can name it. Naming a not-yet-charges-enabled account in a PI fails at creation — so this gates every live merchant charge and is an external, human, per-partner dependency.


B3 — Destination-charge checkout for merchant tenants (the re-plumb — highest blast radius)


B4 — Refund / dispute liability under B (money-movement correctness)

With on_behalf_of, dispute liability + fee sit with the partner by default. Refund handling must change: today handleChargeRefunded (src/lib/webhook-money.ts:412-540) reverses only the editor transfer under auto_reverse_payout, and a refund on a destination charge does not auto-reverse the destination transfer or refund the application fee.


B5 — Monthly seat-fee subscription lifecycle (B-only; PARALLELISABLE with B1–B4)

Independent of transaction volume — paid even at zero bookings. It bills the partner as a PAYER (opposite direction from the Connect payout). There is no existing subscription/Billing surface in the repo (confirmed: only unrelated push_subscriptions), so this is genuinely new ground.


B6 — VAT / merchant-of-record surfacing (light; lands last)

The economic MoR/VAT treatment is an accountant sign-off (D9), not a code decision — but once confirmed, the surfacing is code: the buyer-facing receipt/invoice must reflect the partner as seller for merchant orders, and Redpoint's application fee + monthly seat fee must be invoiced to the partner for UK VAT.


4. REUSE MAP — what carries over vs what's genuinely new

Carries over UNCHANGED from Option A / the current spine (build once, both topologies use):

Reused Where How B uses it
Split math computePartnerCommission src/lib/partner-commission.ts:81-112 Same call; B builds the app fee from editorPayoutMinor + platformFeeMinor and records commissionMinor as the partner's audit figure (§2).
Accrual ledger partner_commissions supabase/migrations/0035_partner_commission.sql:78-124 Same table; B rows get status='native' (new value, B1) — money moved natively, no fired transfer.
tenant_connect table + admin RLS 0035:38-68 Extended additively with topology + seat columns + charges_enabled (B1).
Connect onboarding idiom (atomic settle-on-one-account) buildTenantOnboardingLink src/lib/connect.ts:110-151; route src/app/api/connect/tenant-onboard/route.ts Extended for card_payments + merchant account type (B2) — sibling, editor path untouched.
Editor capture + payout src/lib/transitions.ts:638-718, src/lib/payouts.ts:114-312 Zero change. B funds the editor from the application fee via the same attemptPayout (D6).
Checkout return-URL seam src/lib/checkout.ts:61,77-88 Zero change. payment_intent_data already passes through untouched; B's destination fields ride it.
Two-gate arming safety (commission_live + env) src/lib/partner-commission.ts:265-275 Same pattern carries to any B live-money flip.
Checked-structural Stripe factories src/lib/stripe.ts:28-51 New getStripeBilling() sibling follows the same discipline (B5).

Genuinely NEW (Option B only):

New Packet Why new
Charge TYPE — destination charge (on_behalf_of + transfer_data.destination + application_fee_amount) B3 Re-plumbs the one money-spine charge builder; first time buyer money settles to a partner.
Merchant KYC (card_payments capability + full KYC + charges_enabled gate) B2 A payee account is receive-only; a merchant must be a chargeable settlement account. External per-partner dependency.
Refund/dispute rework (reverse_transfer, refund_application_fee, dispute events, partner-borne fee/liability, ledger re-label) B4 A destination-charge refund does not auto-unwind; liability shifts to the partner.
Monthly seat-fee subscription (Customer-as-payER + Price + Subscription + dunning) B5 No Billing/subscription surface exists today; opposite money direction from the payout.
Merchant-of-record / VAT surfacing B6 Partner becomes seller/VAT-of-record for merchant orders.
topology flag + service-role read seam in createOrder B1 The per-tenant A/B switch; must use svc because tenant_connect is admin-only RLS.

5. RISK + SEQUENCING

Safe build order: B0 (arm A) → B1 (flag, inert) → B2 (KYC, external dep, start early) → B3 (charge re-plumb) → B4 (refund/dispute). B5 (subscription) runs in parallel with B1–B4 (it shares no money-spine code). B6 lands last. Dry-run-first wherever money moves: B3/B4 land behind the topology flag with every current tenant on payee, so a merge moves zero money until a partner is explicitly flipped to merchant and KYC-complete.

Biggest risks (in order):

  1. Checkout re-plumb (B3) — modifies the charge creation for every one-off order (orders.ts:264-301). Mitigation: strict topology gate + the sentinel-binding e2e that a payee/first-party order is byte-identical and a merchant order has the exact application_fee_amount (§2's commissionMinor-would-fail sentinel). The §2 formula ambiguity is the single highest-value thing to get right — a wrong term mis-pays the partner or Redpoint on every order.
  2. Per-partner KYC (B2) — full card_payments KYC is an external, human dependency that can block go-live independently of the code. Start B2 early; the charges_enabled gate must hard-stop a destination charge against a non-ready account (else a buyer hits a Stripe PI-create failure at checkout).
  3. Refund/dispute unwind (B4) — the application-fee-funds-editor model means a refund must claw back BOTH the partner (reverse_transfer) and the editor (auto_reverse_payout); miss one and you get a double-pay or an unfunded refund. Falsification tests required; confirm the exact Stripe flow live (D7).
  4. Subscription lifecycle (B5) — new surface, dunning/suspension correctness (a wrongly-suspended seat blocks a paying partner; a never-suspended delinquent seat is free service). Isolated from the money spine, so its blast radius is contained to seat state.

Cross-cutting live-state checks before asserting (house rule 8 / live-state-truth): verify against current Stripe docs — (i) manual-capture + on_behalf_of fires the destination transfer at capture and lets application_fee_amount be set before capture; (ii) setup_future_usage:"off_session" is compatible with on_behalf_of destination charges; (iii) the exact reverse_transfer/refund_application_fee refund flow and who the platform debits; (iv) application_fee_amount currency == charge currency (drives D10). Each is flagged unconfirmable from code alone and must be checked against Stripe before the relevant packet.


Appendix — grounding (file:line, verified against e67de7c)

Claim Source
One-off Checkout, manual capture, payment_intent_data, metadata src/lib/orders.ts:264-301 (capture :279; PI data :278-284)
createOrder reads only tenants(billing_config, domains) — no tenant_connect today src/lib/orders.ts:151-156 (grep confirmed none)
Checkout seam owns success/cancel, passes payment_intent_data through src/lib/checkout.ts:61,77-88
Both order routes funnel through createOrder + createCheckoutSession(getStripeCheckout()) src/app/api/orders/route.ts:46-61; src/app/api/v1/orders/route.ts:72-84
Capture-then-payout serialization point src/lib/transitions.ts:645-725
Option-A commission leg (reads tenant_connect, accrues, dry-run gated) src/lib/transitions.ts:727-799
Editor payout transfer, kind:"editor_payout", unchanged for B src/lib/payouts.ts:187-312 (:275-280)
Split math computePartnerCommission (commissionMinor = partner residual) src/lib/partner-commission.ts:81-112 (residual :102-108)
Accrual mechanics + two-gate arming src/lib/partner-commission.ts:179-330 (gates :265-275)
tenant_connect (admin-only RLS; stripe_account_id NOT on tenants) supabase/migrations/0035_partner_commission.sql:38-68 (revoke :60)
partner_commissions accrual ledger, status in ('accrued','sent','failed') 0035:78-124 (status :94-95)
Partner Connect onboarding (Express, transfers only) — extend for card_payments src/lib/connect.ts:110-151; route src/app/api/connect/tenant-onboard/route.ts:88-118
Onboarding claim writes tenant_connect.stripe_account_id (connect.ts docstring saying tenants is stale) route :98-115 vs connect.ts:104-108 doc drift
stripe_fee ledger row in settlement currency src/lib/webhook-money.ts:76-92
handleTransferCreated records ONLY editor_payout (A ledger blocker; B partner_commission also unledgered) src/lib/webhook-money.ts:368-393 (:378)
Refund reverses only editor transfer under auto_reverse_payout — no reverse_transfer/refund_application_fee src/lib/webhook-money.ts:412-540 (reversal :482-525)
account.updated matches only profiles.stripe_account_id (must extend to tenant_connect) src/app/api/webhooks/stripe/route.ts:244-282
Webhook event switch; unhandled types default-ACK (dispute/invoice cases are new) src/app/api/webhooks/stripe/route.ts:63,333
Stripe factory discipline (checked structural types) — add getStripeBilling() sibling src/lib/stripe.ts:28-51
No existing subscription/Billing surface (only push_subscriptions) grep confirmed
platform_fee_bps default 0 supabase/migrations/0001_init.sql:32
Topology framed as gating P11 docs/ROADMAP-PHASE-10-PLUS.md "Operator product decisions that GATE build" §1
EUR FX residual (GBP editor fare vs EUR gross) — worse under B's fixed app fee COMMISSION-TOPOLOGY.md §1 blocker 1; D10

Unconfirmable from code (flagged for live Stripe-doc / accountant check before the owning packet): the destination-transfer-at-capture timing and pre-capture application_fee_amount mutability (B3); setup_future_usage × on_behalf_of compatibility (B3); the exact refund unwind flow and platform-debit behaviour (B4); dispute liability + fee attribution under on_behalf_of (B4/D8); the UK VAT treatment of the application fee + seat fee (B6/D9); and application_fee_amount currency handling for EUR (D10).