ADR-0013: Stripe Connect Direct Charges for Subscriptions¶
Status¶
Accepted
Context¶
ONLINE membership subscriptions had to plug into Revel's existing Stripe Connect plumbing (used for ticket sales). Stripe Connect offers three charge models:
- Destination charges — Platform owns the Customer; funds settle to the connected account via a
transfer_data.destinationflag. - Separate charges and transfers — Platform charges; later issues
stripe.Transfer.createto the connected account. - Direct charges — Connected account owns the Customer and the Subscription; the platform takes its cut via
application_fee_percent.
Tickets already use direct charges on Connect — Customers live on the org's Stripe account, Revel scopes API calls with stripe_account=..., and the platform fee is taken via application_fee_percent. Subscriptions needed to fit this same model so an org's Stripe dashboard, payouts, refund tooling, and Customer Portal worked uniformly across tickets and memberships.
Decision¶
Direct charges on Connect for subscriptions, matching the ticket flow.
Specific implications:
CustomerProfileis per-(user, organization). Each Connect account has its own Customer namespace, so a single platform-widestripe_customer_idonRevelUserdoesn't work. Theunique_customer_per_user_orgconstraint enforces one Customer per (user, org);ensure_customer_profileis the single create/lookup entrypoint and uses a deterministicidempotency_key=cust:{user}:{org}to prevent duplicates under concurrent first-time subscribes.- Every Stripe API call is scoped via
stripe_account_kwargs(org). It returns{"stripe_account": org.stripe_account_id}for connected orgs and{}for the platform's own account (mirroringstripe_servicefor tickets). - Customer Portal uses
stripe.billing_portal.Session.create(customer=..., stripe_account=...). Members manage their saved card and download Stripe-hosted invoices on the org's Stripe account, not on the platform. - Webhook routing. A Stripe webhook endpoint listens to either the platform's own account or connected accounts — not both. The platform's webhook is configured for Connect events so ticket and subscription events all flow through the same endpoint. (See
docs/architecture/billing-and-vat.mdfor the "own events vs. connected accounts" caveat.) - Schedules and prices also live on the connected account. The
SubscriptionScheduleused for downgrades and thestripe_product_id/stripe_price_idpair stored onMembershipSubscriptionPlanare all scoped to the org's Connect account.
Consequences¶
- Pro: Uniform model with ticket sales. Refunds, payouts, dispute handling, and Stripe Dashboard access all work the same for an org's staff.
- Pro: Platform fees collected automatically via
application_fee_percent(set fromOrganization.platform_fee_percent— grossed up with platform VAT when applicable — and omitted when the org shares the platform's own Stripe account). Percent-only by Stripe design: subscriptions have no fixedapplication_fee_amountmechanism, soplatform_fee_fixeddoes not apply. - Pro: Members get a Stripe-hosted Customer Portal scoped per organization — payment methods saved against one org never leak to another.
- Con: A new Stripe Customer is created on each Connect account the user subscribes to. Acceptable: members typically subscribe to a small number of orgs, and the per-(user, org) Customer is needed for the portal scoping anyway.
- Con: Direct charges put the connected account on the hook for chargebacks and Stripe's fees. This matches Connect's design and is consistent with ticket sales.
- Con: A bug or compromise in one org's Connect account cannot be contained by platform-level rollback — the connected account owns the Customer/Subscription/Schedule rows.