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/mainate67de7c(Phase-10 slice 10a-2). Option A (PR #68,phase-11-partner-commission) is MERGED into this line (commita413072), so every Option-A artifact this plan builds on —tenant_connect/partner_commissions(0035),src/lib/partner-commission.ts, the commission leg insrc/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):
mainhas since advanced toa1cbd64and 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), EURneeds_fx_reviewholds, 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;0036is now consumed by #71, so B1's first Option-B migration is the next free number — verify againstsupabase/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:
- Prose / decision list (correct): "Set
application_fee_amount = editor_fare + redpoint_commission", whereredpoint_commission= Redpoint's retained cut =round(gross × bps / 10000), "which may be 0". Decision-4/§4.1 calls this the per-tenant commission that "becomesapplication_fee_amount = editor_fare + commission". - Code snippet (suspect): "
computePartnerCommissionis reusable verbatim:application_fee_amount = breakdown.editorPayoutMinor + breakdown.commissionMinor".
breakdown.commissionMinor is the Option-A partner residual — gross − 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.
- Owned scope: operator/finance runbook only (onboard a partner payee, set bps, review accruals, flip the two gates). No repo change beyond possibly the Option-A ledger-leg fix, which is its own separate packet already scoped in COMMISSION-TOPOLOGY.md §1.
- Invariant: B stays unbuilt until A is armed and reconciled once.
- Non-scope: any Option-B code.
- Fail-under-broken gate: N/A (gate).
B1 — Per-tenant topology flag + selection seam (additive, inert at payee)
- Owned scope:
- Migration (additive to the
0035tenant_connecttable; next free number —0036is now taken by #71, so0040at time of writing; probe at build time) per the COMMISSION-TOPOLOGY.md §3 sketch:topology text not null default 'payee' check (topology in ('payee','merchant')), plus the seat-fee columns consumed by B5 (seat_customer_id,seat_subscription_id,seat_status) and a merchant-readiness column consumed by B2/B3 (charges_enabled boolean not null default false— set by theaccount.updatedwebhook, see B2). Widenpartner_commissions.statusto allow'native'(a B row settled natively, no fired transfer):check (status in ('accrued','sent','failed','native')). - The read seam:
createOrder(src/lib/orders.ts) must learn the tenant's topology + partner account id + merchant-readiness. CRITICAL:tenant_connectis admin-only RLS (revoke all … from anon, authenticated;0035_partner_commission.sql:60), andcreateOrder'sreaderis the client's RLS-scoped session client on the human path — it cannot readtenant_connect. The topology read MUST go throughdeps.svc(service-role), scoped.eq("tenant_id", tenantId). Add a single service-role read oftenant_connect(topology, stripe_account_id, charges_enabled)near the existingtenantsread (orders.ts:151-156).
- Migration (additive to the
- Invariant: with
topology='payee'(the default and every backfilled row),createOrder's output — the order row, the checkout params,payment_intent_data— is byte-identical to today. The new read is inert. - Non-scope: no charge-shape change yet (that is B3); no subscription columns used yet (B5); no onboarding change (B2). This packet only lands the column(s) and the inert read.
- Fail-under-broken gate: a unit/e2e that drives
createOrderfor apayeetenant and asserts the emittedpayment_intent_datahas noon_behalf_of/transfer_data/application_fee_amountkeys and is deep-equal to the pre-change shape; and a probe-and-skip guard (house pattern) so local (pre-migration DB) stays green while CI (all-migrations DB) enforces the new column. - Consumes: D1 (which bps column).
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.
- Owned scope:
- Extend
buildTenantOnboardingLink(src/lib/connect.ts:110-151) to requestcard_payments(in addition totransfers) for merchant-topology tenants — keyed on topology so apayeeonboard is unchanged. Likely a non-Express account type (Standard/Custom) depending on who owns the dashboard/UX (flag: account-type is a product decision — Express hosted-onboarding vs Standard partner-owned dashboard). Keep it a sibling of the editor builder (the editor path stays byte-identical, per the §1 discipline). - Extend the
account.updatedwebhook handler (src/app/api/webhooks/stripe/route.ts:244-282) — which today matches onlyprofiles.stripe_account_idand logs a warning otherwise — to also matchtenant_connect.stripe_account_idand mirrorcharges_enabled(andpayouts_enabledif surfaced) ontotenant_connect. Today a partner-accountaccount.updated"matched no profile" warning is the norm; that must become a real sync. - A charge-eligibility guard in the B3 checkout branch: if
topology='merchant'andcharges_enabledis false, do not emit a destination charge (refuse the order with a clear error, or — decision — fall back). Prevents a hard Stripe PI-create failure at the buyer's checkout.
- Extend
- Invariant: editor onboarding + editor
payouts_enabledsync is untouched; apayeepartner onboard still requests onlytransfers. - Non-scope: the charge itself (B3); the seat subscription (B5).
- Fail-under-broken gate: a unit test that a merchant-topology onboarding requests
card_payments; a webhook unit test thataccount.updatedfor atenant_connectaccount writescharges_enabled; and a checkout guard test that a merchant tenant withcharges_enabled=falsenever emitson_behalf_of. - Consumes: account-type decision (flagged above; sits under D9's MoR umbrella).
B3 — Destination-charge checkout for merchant tenants (the re-plumb — highest blast radius)
Owned scope: in
createOrder's one-off branch (src/lib/orders.ts:264-301), whentopology='merchant'and the partner ischarges_enabled, add topayment_intent_data(these ride the existingcreateCheckoutSessionseam untouched — the seam owns onlysuccess_url/cancel_urland passespayment_intent_datathrough,src/lib/checkout.ts:61,83-87):payment_intent_data: { capture_method: "manual", // UNCHANGED — authorize now, capture at approval on_behalf_of: partnerAccountId, // merchant of record + dispute/VAT/fee shift transfer_data: { destination: partnerAccountId }, application_fee_amount: editorFareMinor + platformFeeMinor, // §2 — NOT commissionMinor setup_future_usage: "off_session", // confirm compatibility under on_behalf_of (flag) metadata: { order_id, tenant_id }, }Compute
application_fee_amountat checkout from the frozen fare (fare,orders.ts:225-226) and the per-tenant bps, viacomputePartnerCommissioninputs — the fare is already frozen here, so no pre-capture update is needed. Record the B accrual intopartner_commissionswithstatus='native'(money moves natively; nothing is transferred by us) so a B row is auditable alongside A rows. Thepayee/first-party branch leavespayment_intent_dataexactly as today.Editor-payout-under-B (D6): no code change to
src/lib/payouts.ts. At approval, capture fires the destination transfer to the partner and leavesapplication_fee_amounton Redpoint's platform balance; the existingattemptPayout(payouts.ts:187-312) transfers the editor fare from that balance exactly as today. This is why the application fee must include the editor fare (§2). Confirm against current Stripe destination-charge docs that manual-capture +on_behalf_offires the destination transfer at capture (COMMISSION-TOPOLOGY.md §2(a) asserts this — verify live before build).Invariant: a
payee/first-party order emits the identical PI shape as today (noon_behalf_of/transfer_data/application_fee_amount); amerchantorder emits a destination charge whoseapplication_fee_amount = editor_fare + platform_fee. Editor payout mechanics unchanged for both.Non-scope: refund/dispute rework (B4); subscription (B5). Do not touch
payouts.ts.Fail-under-broken gate (sentinel-binding, must fail under broken behaviour):
payeeorder →payment_intent_datadeep-equals the pre-change shape (no destination keys).merchantorder →on_behalf_of === transfer_data.destination === partnerAccountIdandapplication_fee_amount === editorFareMinor + platformFeeMinor(a test that would PASS if the code wrongly usedcommissionMinormust FAIL — pick sentinel fare/gross/bps whereplatformFeeMinor ≠ commissionMinor, e.g. gross 10000, fare 6000, bps 1000 → platformFee 1000, commission 3000-ish: app fee must be 7000, not 9000).merchant+charges_enabled=false→ no destination charge emitted (B2 guard).
Consumes: D1, D2 (the formula), D3, D6, D10.
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.
- Owned scope (topology-branched,
merchantonly):- Full refund unwind: claw the partner's share (
reverse_transfer:trueon the refund), deciderefund_application_feeper D7, and keep the existing editor clawback (auto_reverse_payout) because the editor was funded from the application fee. Partial refunds: ops-decided (as today). - Refund funding: on a destination charge the refund debits the platform by default;
reverse_transferpulls the partner's portion back — confirm the exact flow against Stripe docs + accountant (D7) before writing. - Ledger semantics: the
stripe_feeledger row is denominated in the settlement currency (webhook-money.ts:76-92); underon_behalf_ofthe fee is the partner's, so the engine-sidestripe_feerow may no longer be Redpoint's cost — reconcile / re-label for merchant rows so job P&L isn't polluted with a cost Redpoint didn't bear. - Dispute events: add
charge.dispute.created/charge.dispute.closedcases (today unhandled → default-ACK atsrc/app/api/webhooks/stripe/route.ts:333) to record the partner-borne dispute for ops visibility and, on a lost dispute, surface the already-paid-editor exposure (D8).
- Full refund unwind: claw the partner's share (
- Invariant:
payee/first-party refund + reversal behaviour is untouched (the existingauto_reverse_payoutpath stays byte-identical); merchant-row changes are gated on topology. - Non-scope: the checkout charge (B3); subscription dunning (B5, which is its own invoice path).
- Fail-under-broken gate: unit tests over
handleChargeRefundedwith a merchant charge assertingreverse_transfer/refund_application_feeper D7 and that the editor clawback still fires; a test that apayeerefund is unchanged; a dispute-event test that a merchant dispute is recorded and apayeecharge path is unaffected. This is the place a double-pay or unfunded-refund bug hides — falsification tests required. - Consumes: D3, D7, D8.
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.
- Owned scope:
- Stripe objects: a Customer on Redpoint's platform representing the partner as payer (distinct from the partner's Connect payEE account — two objects, same partner), a saved payment method, a recurring Price per seat tier (GBP primary), and a Subscription created on onboard.
- New Stripe SDK surface: a
getStripeBilling()factory sibling insrc/lib/stripe.tsnarrowingcustomers.create,subscriptions.create/cancel,prices/products(same checked-structural-type discipline asgetStripeMoney/getStripeConnect/getStripeCheckout,src/lib/stripe.ts:28-51), and asrc/lib/seat-subscription.tsmodule that is import-free at runtime (fake Stripe in the unit gate), mirroringconnect.ts/payouts.ts. - Lifecycle: create subscription on onboard (store
seat_customer_id/seat_subscription_id/seat_statusontenant_connect, B1 columns); cancel on offboard; dunning via Stripe smart retries + new webhook casesinvoice.paid/invoice.payment_failed/customer.subscription.deleted(all today default-ACK atwebhooks/stripe/route.ts:333) → after N failures (D5) setseat_statusand apply the suspension effect (D5 — e.g. block new orders /tenants.active=false). - Admin route to start/stop a seat (sibling of
src/app/api/connect/tenant-onboard/route.ts, admin-only).
- Invariant: the seat subscription touches no money-spine code — capture, payout, commission, refund are all unaware of it. A tenant with no seat subscription (every current tenant) is unaffected.
- Non-scope: the transaction charge (B3/B4). The seat fee is NOT an
application_feeand NOT a Connect transfer — keep the two Stripe object graphs (payER Customer vs payEE Connect account) strictly separate. - Fail-under-broken gate: unit tests over
seat-subscription.ts(create/cancel idempotency; ainvoice.payment_failed→seat_statustransition after N failures per D5; suspension effect applied); a test that a non-seat tenant is never touched by the invoice webhooks. - Consumes: D4, D5.
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.
- Owned scope: show merchant-of-record correctly on the order-success/receipt surfaces for
merchantorders (partner as seller, not Redpoint); ensure the seat-fee subscription invoices (B5) and the application-fee reporting carry the right VAT treatment per D9; ops finance visibility that a merchant order's VAT is the partner's, not Redpoint's. - Invariant:
payee/first-party receipts + VAT reporting are unchanged (Redpoint remains MoR there). - Non-scope: the charge (B3); the accountant's determination itself (D9 — external).
- Fail-under-broken gate: a rendering/sentinel test that a
merchantorder's receipt names the partner as seller and apayeeorder still names Redpoint. - Consumes: D9.
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):
- 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 apayee/first-party order is byte-identical and amerchantorder has the exactapplication_fee_amount(§2'scommissionMinor-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. - Per-partner KYC (B2) — full
card_paymentsKYC is an external, human dependency that can block go-live independently of the code. Start B2 early; thecharges_enabledgate must hard-stop a destination charge against a non-ready account (else a buyer hits a Stripe PI-create failure at checkout). - 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). - 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_amountmutability (B3);setup_future_usage×on_behalf_ofcompatibility (B3); the exact refund unwind flow and platform-debit behaviour (B4); dispute liability + fee attribution underon_behalf_of(B4/D8); the UK VAT treatment of the application fee + seat fee (B6/D9); andapplication_fee_amountcurrency handling for EUR (D10).