ADR-0012: Hybrid OFFLINE/ONLINE Membership Subscriptions¶
Status¶
Accepted
Context¶
Revel needed recurring membership revenue alongside one-off ticket sales. Two patterns were on the table:
- Stripe-only: All subscriptions are Stripe Subscriptions on the org's Connect account. No platform-side state machine; we just mirror Stripe.
- Hybrid OFFLINE/ONLINE: One local
MembershipSubscriptionstate machine that can be backed by Stripe (ONLINE) or managed entirely by staff (OFFLINE), with cash, bank transfers, or any other off-Stripe collection recorded viaMembershipPaymentrows.
A pure Stripe model would have been faster to ship but locks every customer out of platforms or fee categories Stripe doesn't support — non-profits with cash-paying members, organizations whose members pay annually by SEPA, orgs on Stripe-restricted geographies, and orgs not yet onboarded to Connect at all. The OFFLINE flow also unblocked staff-managed migrations from spreadsheets/legacy systems without a payment-method change for existing members.
Decision¶
Option 2: hybrid. One local state machine (MembershipSubscription.status ∈ {PENDING, ACTIVE, PAUSED, PAST_DUE, CANCELLED, EXPIRED}) is authoritative for both flows. MembershipSubscriptionPlan.payment_method decides which side drives transitions:
payment_method | Period advancement | State authority |
|---|---|---|
OFFLINE | Staff calls record_payment | Local service + daily Celery beat |
ONLINE | Stripe invoice.paid webhook | Stripe (mirrored locally) |
FREE | Never — LIFETIME by construction | Local service only (nothing advances it) |
FREE (#832) was added later as a third mode rather than a boolean flag on OFFLINE: it differs on the two axes that actually matter here — who may create it (members self-serve, unlike OFFLINE) and whether the beats own it (they never see it, since LIFETIME leaves current_period_end NULL). The same reasoning is why LIFETIME is a period_unit rather than a one_off flag: period arithmetic already dispatches on that field, so the non-renewing case falls out of the existing branch instead of adding a parallel one.
Key consequences of the split:
- One status enum, one state graph. Member-facing UI, admin tooling, the
OrganizationMembersync signal, and metrics all read a singlestatusfield regardless of payment_method. - Service-layer dispatch.
cancel_subscription,pause_subscription,resume_subscription,change_planetc. checkplan.payment_methodand route tosubscription.stripe.checkout(or the dedicatedsubscription.stripe.plan_changemodule) for ONLINE flows. Controllers do not branch. payment_methodis not patchable. Switching an existing subscription between OFFLINE and ONLINE would require non-trivial Stripe migration with no obvious correct period reconciliation. The plan must be archived and a new plan created instead (PlanUpdateSchemaomits the field;change_planrefuses cross-method changes).- Member-creation responsibility differs. OFFLINE and FREE:
create_subscriptionensuresOrganizationMemberexists up front. ONLINE: the local row is created in PENDING;_ensure_active_memberonly runs after Stripe confirms the first invoice (no tier benefits before payment). FREE has no webhook that could ever run, socreate_subscriptionis its only materialization point — and the originating application is settled COMPLETED insubscription.eligibility.subscribe_to_planfor the same reason.
Consequences¶
- Pro: One code path for permissions, member sync, expiry, reporting, and notifications. Adding a third payment method later (e.g. ACH) is a new branch in the service layer, not a parallel state machine.
- Pro: Organizations can run a mix of OFFLINE plans (e.g. cash-paying legacy members on a "Founders" tier) and ONLINE plans (e.g. Stripe-billed Monthly/Annual) on the same tier hierarchy.
- Con: Some operations have two implementations (cancel, pause, resume, change_plan, revive). The dispatch is concentrated in
subscription/lifecycle.pyand verified by paired tests (test_subscription/lifecycle.py+test_subscription/stripe/checkout.py). - Con: ONLINE payments are never hand-recorded —
record_paymentaccepting an ONLINE plan would create duplicates with theinvoice.paidwebhook. The admin record-payment endpoint refuses ONLINE subscriptions explicitly. - Con: The duplicated
stripe_account_kwargshelper insubscription/stripe/plan_change.pyexists to avoid an import cycle withsubscription.stripe.checkout. Accepted as a small, contained price.