Membership Subscriptions¶
Revel lets organizations charge recurring membership fees, with three collection modes that share one local state machine:
- OFFLINE: Staff records payments by hand (cash, bank transfer, anything non-Stripe). The grace-expiry Celery beat task drives the state machine.
- ONLINE: Stripe Subscriptions on the org's Connect account. Stripe drives the state machine via webhooks; the local row mirrors what Stripe says.
- FREE (#832): no money at all. Members self-subscribe and the row lands ACTIVE immediately — no Stripe object, no checkout, nothing to collect. FREE plans are necessarily
LIFETIME, so nothing ever renews or lapses either. This is the "free but gated" membership: the tier's questionnaire and manual-approval gates still have to pass, which is the whole reason the mode exists (a plan-less tier joins through/apply, which cannot enforce a subscription cap or a paused sale).
The split is captured in ADR-0012; the Stripe Connect model is captured in ADR-0013; the revival flow rules are captured in ADR-0014; the dunning + audit choices are captured in ADR-0015.
Architecture Overview¶
flowchart LR
subgraph Members["Member-facing"]
MeAPI["/api/me/...<br>controllers/me_subscriptions.py"]
end
subgraph Staff["Staff-facing"]
AdminAPI["/api/organization-admin/.../<br>controllers/organization_admin/subscriptions.py"]
end
subgraph Services["Service layer"]
Sub[subscription.lifecycle]
Stripe[subscription.stripe.checkout]
PlanChange[subscription.stripe.plan_change]
Dispatch[subscription.stripe.dispatch]
Report[subscription.reporting]
end
subgraph Async["Async"]
Beat["Celery beat<br>expire_subscriptions_past_grace<br>send_subscription_renewal_reminders<br>reconcile_stripe_subscriptions"]
Webhook["Stripe webhook<br>stripe_webhook.py"]
end
subgraph Data["Data"]
Plan[(MembershipSubscriptionPlan)]
SubModel[(MembershipSubscription)]
Pay[(MembershipPayment)]
Cust[(CustomerProfile)]
Member[(OrganizationMember)]
end
MeAPI --> Sub
MeAPI --> Stripe
AdminAPI --> Sub
AdminAPI --> Report
Sub --> Stripe
Sub --> PlanChange
Stripe --> Dispatch
Sub --> SubModel
Stripe --> SubModel
Stripe --> Cust
Stripe --> Pay
Beat --> Sub
Webhook --> Stripe
SubModel -.post_save signal.-> Member Data Model¶
All in src/events/models/subscription.py. Re-exported via events/models/__init__.py.
MembershipSubscriptionPlan¶
| Field | Notes |
|---|---|
tier | FK to MembershipTier — multiple plans per tier (Monthly, Annual, …) |
name, description | Operator-controlled |
price, currency | Decimal(10,2) + ISO 4217. Currency is validated against the platform's supported list. Bound to payment_method — see the shape rules below |
period_unit, period_count | Rolling cadence — (MONTH, 1), (MONTH, 3), (YEAR, 1), … — or LIFETIME, a non-renewing term. LIFETIME ignores period_count, leaves current_period_end NULL forever, and is therefore invisible to every renewal/lapse/expiry beat (they all filter on that column, which SQL NULL never matches) |
is_active | Archived (False) plans are hidden from public listings but keep their FKs |
sales_status | OPEN (default) / PAUSED. Pausing stops member self-service sales (subscribe, revive, switching into the plan) without touching existing subscribers; staff endpoints bypass it. Orthogonal to archiving: archived = retired, paused = temporarily closed |
max_subscriptions | Cap on concurrent non-terminal subscriptions (the venue's "card stock"). NULL = unlimited. Counted live under a plan-row lock, so slots reclaim automatically when a subscription terminalizes — no counter to drift. Hard limit: applies to staff too |
payment_method | OFFLINE (default), ONLINE, or FREE. Not patchable — see ADR-0012 |
stripe_product_id, stripe_price_id | Provisioned lazily by ensure_stripe_price on the org's Connect account. Always empty for OFFLINE and FREE |
history | simple_history audit table |
Constraint: unique_plan_name_per_tier(tier, name).
Plan shape rules¶
payment_method constrains price and period_unit. Enforced once in events/utils/subscription_plan_rules.py:validate_plan_shape, called from both PlanCreateSchema (422 at create) and subscription.lifecycle.update_plan (400 at patch, against the merged post-patch values) so the two cannot drift.
payment_method | price | period_unit | Why |
|---|---|---|---|
OFFLINE | ≥ 0 | any, incl. LIFETIME | Staff settle off-book; LIFETIME expresses a one-off paid membership |
ONLINE | > 0 | MONTH / YEAR only | Stripe has nothing to bill at 0, and its recurring intervals are month/year — a paid lifetime membership is expressible as OFFLINE, which has no Stripe object |
FREE | = 0 | LIFETIME only | A finite free term would be selected by the lapse beat and expire with no way to renew (there is no payment to record) |
Sale controls¶
ensure_plan_on_sale (PAUSED check, member paths only — staff callers pass enforce_sales_status=False) and ensure_plan_sales_capacity (cap check, everyone) live in subscription.lifecycle and are enforced on create, revive and plan switches. The capacity check select_for_updates the plan row so concurrent creations serialize, mirroring the capacity-reclaim invariants of ticket tiers. The public plan schema exposes sales_status and a computed sold_out flag so the frontend can render "sold out" vs "sales paused" vs a normal subscribe CTA.
MembershipSubscription¶
| Field | Notes |
|---|---|
user, plan, organization | organization is denormalized for permission/query simplicity; clean() enforces plan.tier.organization_id == organization_id |
status | See State Machine |
current_period_start / current_period_end | Driven by record_payment (OFFLINE) or invoice.paid (ONLINE). On a LIFETIME plan current_period_end stays NULL forever — that NULL is what keeps the beats away from it |
cancel_at_period_end, cancelled_at | Scheduled vs. immediate cancellation |
expired_at | Stamped on every transition into EXPIRED. Drives revival window |
stripe_subscription_id | Unique; nullable. Populated after the Stripe Subscription is created |
pending_plan, stripe_schedule_id | Downgrade flow: the schedule's second phase resolves on customer.subscription.updated |
history | simple_history audit table |
Class attribute TERMINAL_STATUSES = frozenset({"cancelled", "expired"}) is the single runtime source of truth. The same tuple (_TERMINAL_STATUS_VALUES) is referenced from Meta.constraints so the partial-unique index and the runtime check can never diverge.
Constraints / indexes:
one_active_subscription_per_user_org— partial unique on(user, organization)excluding terminal statuses. Lets a member create a fresh subscription cleanly once a previous one is CANCELLED or EXPIRED.Index(organization, status)andIndex(user, status)— list/dashboard queries.Index(current_period_end)— daily scan in the expiry beat task.
MembershipPayment¶
A payment recorded against a subscription. OFFLINE: created by record_payment. ONLINE: created/upserted by record_stripe_payment_from_invoice on invoice.paid / invoice.payment_failed. The unique-by-side-effect key is stripe_invoice_id (uses update_or_create for webhook idempotency).
CustomerProfile¶
Per-(user, organization) Stripe Customer reference. Created lazily by ensure_customer_profile. Two constraints keep duplicates out: unique_customer_per_user_org(user, org) and unique_stripe_customer_per_org(organization, stripe_customer_id).
Organization additions¶
membership_grace_period_days(default7) — how long PAST_DUE lasts before EXPIRED.membership_refund_policy— markdown, displayed to members.membership_subscription_revival_window_days(default30) — how long afterexpired_atrevival is allowed.0disables revival entirely.
State Machine¶
record_payment
PENDING ─────────────────────────────────► ACTIVE ◄───────────────────┐
│ (ONLINE: invoice.paid) │ ▲ │
│ │ │ resume │ record_payment / invoice.paid
│ pause ▼ │ │
│ PAUSED │
│ │
ACTIVE ──(period_end<now, !cancel_at_period_end, Celery)──► PAST_DUE ─┘
ACTIVE ──(period_end<now, cancel_at_period_end=True, Celery)──► CANCELLED (terminal)
PAST_DUE ──(period_end + grace_days < now, Celery)──► EXPIRED (terminal)
ACTIVE/PAUSED ──(cancel_subscription, immediate=True)──► CANCELLED (terminal)
ACTIVE/PAUSED/PAST_DUE ──(cancel_subscription, immediate=False)──► (unchanged, cancel_at_period_end=True)
EXPIRED ──(revive_subscription within window)──► ACTIVE (OFFLINE) / PENDING (ONLINE, awaits invoice.paid)
Key invariants:
- Terminal subscriptions (
CANCELLED,EXPIRED) never advance their period.record_paymentraises 400;_apply_stripe_price_swapshort-circuits. - CANCELLED vs. EXPIRED is the revival discriminator. EXPIRED means an involuntary lapse —
expired_atis stamped and the org's revival window applies. CANCELLED means the member (or staff) chose to stop, whether immediately or at the period boundary;expired_atstaysNULLandrevive_subscriptionrefuses the row. A lapsed ACTIVE row withcancel_at_period_end=Truetherefore lands CANCELLED, not EXPIRED (_terminalize_cancelled_row), so the member is never offered a "revive?" CTA for a cancellation they asked for. - Subscription tier wins over member tier on the
OrganizationMembersync signal — see Member Sync. - ONLINE subscriptions in PENDING do not grant ACTIVE membership. The member is only synced ACTIVE once Stripe confirms the first invoice (or the Stripe webhook reports
status=active).
Service Layer¶
The service lives in the events/service/subscription/ package: the local state machine at the top level (core, lifecycle, plans, sales, eligibility, uncancel, refunds, reporting, notifications) and the Stripe integration under subscription/stripe/. Every public symbol is re-exported from subscription/__init__.py.
events/service/subscription/lifecycle.py¶
Function-based service that owns the per-subscription OFFLINE flow and the dispatch layer for ONLINE operations. Plan CRUD and price migration live in subscription/plans.py.
| Function | Responsibility |
|---|---|
create_subscription | Refuses BANNED users, duplicate non-terminal subs, and cap-full plans (ensure_plan_sales_capacity, under the plan-row lock); auto-creates an OrganizationMember at plan.tier. OFFLINE and FREE only — ONLINE goes via start_online_subscription so the user can confirm payment. A FREE plan additionally lands the row ACTIVE with current_period_start=now and a NULL period end (nothing to pay, nothing to renew) |
record_payment | Advances current_period_*, revives PENDING/PAST_DUE → ACTIVE. Refuses terminal. Dispatches RENEWAL_SUCCEEDED only on a real renewal (prior_status ∈ {ACTIVE, PAST_DUE}). dispatch_renewal_notification=False for revival callers |
cancel_subscription | immediate=True → CANCELLED. immediate=False → cancel_at_period_end=True and let the beat task finish it. Refuses scheduled cancel on PAUSED (frozen time would never reach the boundary). A period-less row — any LIFETIME/FREE subscription, or an unpaid checkout — has no boundary to wait for, so a scheduled cancel is upgraded to immediate (the one exception is a row whose Stripe Subscription is already linked: Stripe owns that boundary). Routes ONLINE through cancel_online_subscription |
pause_subscription / resume_subscription | Local for OFFLINE; routes to Stripe pause_collection for ONLINE. Refuses ONLINE without stripe_subscription_id to keep local PAUSED in lockstep with Stripe |
revive_subscription | EXPIRED → ACTIVE (OFFLINE, with initial_payment — staff callers only; the member endpoint refuses OFFLINE plans) or PENDING + hosted Checkout for a fresh Stripe Subscription (ONLINE); staff-initiated ONLINE revivals also email the member the checkout link (SUBSCRIPTION_REVIVAL_CHECKOUT). Enforces the plan cap (a revived sub re-occupies a slot) and, for member callers, sales_status (enforce_sales_status=False for staff) |
change_plan | Routes ONLINE to subscription.stripe.plan_change.change_online_plan; OFFLINE does an immediate same-org/same-currency swap |
refund_payment | (in subscription.refunds) Marks payment REFUNDED and stamps refund_amount / refunded_at. If the refund fully covers the current period → _cancel_refunded_subscription, not cancel_subscription(immediate=True): the refund callers already hold row locks, so the local row is terminalized (CANCELLED) under the lock and the Stripe-side cancel is deferred to on_commit as a best-effort call — never a network call under a row lock |
_dispatch_* | Private notification helpers — one per NotificationType. Always render via the existing notification dispatcher |
events/service/subscription/plans.py¶
Everything that operates on a plan rather than on one subscriber's lifecycle.
| Function | Responsibility |
|---|---|
create_plan / update_plan / archive_plan / delete_plan | Plan CRUD. ONLINE plans trigger ensure_stripe_price. Currency edits are refused when active subs exist. delete_plan catches ProtectedError from the FK |
migrate_plan_subscribers | Force-migrates non-terminal subs on a plan to its current Stripe price (proration_behavior='none'). Per-sub errors are reported individually; no rollback. Batches the "previous price" lookup with DISTINCT ON to avoid N+1 |
events/service/subscription/stripe/checkout.py¶
| Function | Responsibility |
|---|---|
ensure_customer_profile | Get-or-create the per-(user, org) Stripe Customer. Deterministic idempotency_key=cust:{user}:{org} |
ensure_stripe_price | Create/refresh Stripe Product+Price for an ONLINE plan. Detects price-input changes (_price_inputs_changed) and archives the old Price + creates a new one (Stripe Prices are immutable) |
archive_stripe_price | Deactivates the Stripe Price when a plan is archived — existing subscribers keep paying via their own subscription's price binding |
start_online_subscription | Refuses PAUSED plans (member path), then creates local PENDING row, then a hosted Checkout Session (mode='subscription') on the org's Connect account. Returns checkout_url; the session id lands on stripe_checkout_session_id and the Stripe Subscription is only created (and linked via checkout.session.completed) when the member completes the session. Rolls back the local row if Stripe fails. An existing PENDING row is resumed (same session url) while its session is still open on the same plan, expired + cleared when superseded (revival rows revert to EXPIRED instead of deleting the ledger), or answered with a SubscriptionActivationPendingError (409, code=subscription_activation_pending) when the session already completed and only the activation webhooks are outstanding |
create_revival_checkout | Mints a hosted Checkout Session for a fresh Stripe Subscription on an EXPIRED row (old Stripe sub cancelled first — a failure here fails the revival, since the id is about to be cleared — then stripe_subscription_id cleared, row → PENDING). Idempotency key is per attempt (sub-revival:{pk}:{uuid4}): anchoring it on expired_at would repeat the key across retries after an abandoned checkout (which preserves expired_at) while the session's expires_at is recomputed from now() — same key, different params, which Stripe rejects for ~24h. The caller's row lock serializes concurrent attempts, and a mode=subscription session only charges on completion, so an orphaned session cannot double-charge. The old stripe_subscription_id survives in historical_membership_subscription |
cancel_online_subscription, pause_online_subscription, resume_online_subscription | Stripe mutations + local mirror. The dispatch helpers in subscription.lifecycle cover the OFFLINE-equivalent local-only behavior |
update_subscription_price | Single-call Stripe price swap used by migrate_plan_subscribers. proration_behavior='none' — new price takes effect at the next renewal |
create_billing_portal_session | Customer Portal URL on the org's Connect account. Requires an existing CustomerProfile (404 otherwise) — only members who have actually subscribed can get a portal session |
sync_subscription_from_stripe | Mirrors customer.subscription.{created,updated,deleted} payloads. Uses _resolve_target_status (terminal wins over pause_collection), _apply_period_dates, and _apply_stripe_price_swap (terminal rows frozen) |
record_stripe_payment_from_invoice | Upserts the MembershipPayment row on invoice.paid / invoice.payment_failed. update_or_create(stripe_invoice_id=...) is the idempotency anchor |
events/service/subscription/stripe/fees.py¶
The platform's application_fee_percent — the only fee mechanism Stripe offers for subscriptions, so the org fee and the VAT on it are folded into a single percent (effective_application_fee_percent; the fixed fee component does not apply to subscriptions). resync_subscription_application_fees pushes today's percent onto every live ONLINE subscription when an org's VAT status changes, skipping schedule-managed rows (Stripe rejects a plain Subscription.modify while a schedule is attached) and counting per-subscription failures instead of raising.
events/service/subscription/stripe/plan_change.py¶
Extracted from subscription/stripe/checkout.py to stay under the 1000-line file limit. All plan-change logic for ONLINE subs:
_classify_plan_changenormalizes both prices to a per-month figure (_monthly_equivalent_price) before comparing, so a Monthly → Annual swap is classified on per-month cost, not raw headline price._upgrade_online_subscription(more expensive per month): immediate StripeSubscription.modifywithproration_behavior='always_invoice'andpayment_behavior='allow_incomplete'. Local plan is swapped synchronously so the API response reflects the change without waiting for the webhook — which is exactly why the proration must be invoiced on the spot rather than merely queued.create_prorationsonly parks line items on the next invoice, so the member would hold the pricier tier free for a whole cycle, and an immediate cancel (Stripe defaults toinvoice_now=False, prorate=False) would discard the delta outright. If the invoice fails, the subscription goespast_dueand the normal dunning flow takes over; the price swap stands either way._downgrade_online_subscription(cheaper or equal per month): StripeSubscriptionSchedulewith two phases (current price for the rest of the period; new price after, for adurationof one new-plan billing period —iterationswas removed by the pinned Stripe API version).end_behavior='release'falls back to a normal rolling renewal at the new price once that second phase completes. Local row carriespending_plan+stripe_schedule_iduntil the phase transition arrives viacustomer.subscription.updated, at which point_apply_stripe_price_swapclears both fields.
events/service/subscription/stripe/dispatch.py¶
Notification dispatch helpers for the Stripe sync/invoice code paths. Holds the gates that prevent double-firing on re-delivered webhooks:
_dispatch_sync_notifications— gated on transitions captured byprior_status/prior_capsnapshots._dispatch_invoice_notifications— gated onpayment_created(fromupdate_or_create) andprior_statusso the first invoice of a brand-new subscription does not fireRENEWAL_SUCCEEDED.
events/service/subscription/reporting.py¶
Per-organization metrics for the admin dashboard:
active_count= ACTIVE + PAST_DUE (still-paying customers).mrr= sum, over theactive_countsubs, of each sub's monthly equivalent. The per-sub amount is the subscriber's latest successful non-proration payment, falling back toplan.pricewhen there is none (OFFLINE pre-payment, brand-new rows) — grandfathered ONLINE subscribers keep paying their old Stripe price, soplan.pricealone would overstate them, and a one-off proration charge from a plan change is not a recurring amount. A single batchedDISTINCT ONquery fetches those amounts, keeping the per-sub loop N+1-free. Annual plans divide byperiod_count * 12. The sum is quantized once at the end (Decimal("0.01")) to avoid accumulated rounding drift.mrr_currency= the shared currency, or"MIXED"(withmixed_currency_warning=Trueandmrr=0) if the org has subs in multiple currencies.churned_30d= subs that transitioned to CANCELLED/EXPIRED in the last 30 days (cancelled_at >= cutoff OR expired_at >= cutoff).churn_rate_30d=churned_30d / (active_count + churned_30d)— intentional approximation; see Phase 4 spec §9.
A single aggregate query produces the status breakdown; the MRR loop uses select_related("plan") plus the batched payment lookup above to avoid N+1. LIFETIME plans normalize to 0 monthly: they are one-off, so folding them into MRR would inflate it permanently off a payment that happens once — or, for FREE plans, never.
events/utils/subscription_periods.py¶
Pure utilities:
calculate_period_end(period_start, plan)— usesdateutil.relativedeltaso Jan 31 + 1 month → Feb 28/29 behaves correctly. Explicit branch onplan.period_unit(MONTH/YEAR) so a future unit isn't silently treated as months. ReturnsNoneforLIFETIME; callers must propagate that NULL rather than substituting a date. (record_paymentis the one place that needs a non-null value anyway —MembershipPayment.period_endisNOT NULL— so a lifetime payment is recorded as the point event it is,period_end == period_start, while the subscription's owncurrent_period_endstays NULL.)REMINDER_DAYS = 3— used by the renewal-reminder beat task.
Permissions¶
A new manage_subscriptions permission on PermissionMap (default: owner ✓, staff ✓, member ✗). Used by OrganizationPermission("manage_subscriptions") on the admin controller. Members access their own subscriptions through the JWT-authenticated MeSubscriptionsController (no permission gate; self.user() scopes every query).
Member Sync (signal)¶
events/signals.py::sync_member_from_subscription listens to post_save on MembershipSubscription:
| Subscription status | OrganizationMember status |
|---|---|
| PENDING (ONLINE) | unchanged — waits for invoice.paid |
| PENDING (OFFLINE) / ACTIVE / PAST_DUE | ACTIVE |
| PAUSED | PAUSED |
| CANCELLED / EXPIRED | CANCELLED |
Rules baked into the signal:
- Never creates a member. Creation lives in
create_subscription(OFFLINE and FREE) or_ensure_active_member(ONLINE, gated on the first paid invoice). - Leaves BANNED alone. A banned member stays banned regardless of subscription activity.
- Subscription tier wins.
member.tieris updated toplan.tieron every sync. - Older terminal rows do not clobber. If a newer non-terminal sub exists for the same (user, org), the signal returns early — re-saving a historical CANCELLED row from the admin must not flip the user back to CANCELLED.
Async Surface¶
Celery tasks¶
All in src/events/tasks/subscriptions.py.
| Task | Schedule | Purpose |
|---|---|---|
events.expire_subscriptions_past_grace | Daily, 04:00 UTC (migration 0070) | ACTIVE lapsed → CANCELLED when cancel_at_period_end (chosen cancel: cancelled_at stamped, expired_at left unset, no notification — CANCELLATION_CONFIRMED already fired), else → PAST_DUE; PAST_DUE past grace → EXPIRED. Dispatches PAYMENT_FAILED (OFFLINE) and SUBSCRIPTION_EXPIRED. ONLINE rows terminalized here are queued for a best-effort Stripe cancel after the row locks are released, so Smart Retries stop dunning them |
events.send_subscription_renewal_reminders | Daily, 05:00 UTC (migration 0102) | Fires SUBSCRIPTION_RENEWAL_REMINDER for ACTIVE subs whose current_period_end.date() == today + REMINDER_DAYS and cancel_at_period_end=False. The target date comes from timezone.localdate(), matching the __date lookup's local-timezone rendering |
events.reconcile_stripe_subscriptions | Nightly, 03:30 UTC (migration 0103) | Drift repair: re-retrieves each ONLINE Stripe Subscription and feeds it through sync_subscription_from_stripe (Stripe call outside the per-row transaction). Also sweeps stale PENDING checkout rows holding a cap slot, and backfills paid invoices whose invoice.paid was lost |
events.migrate_plan_subscribers | On demand | Async wrapper for the staff "migrate subscribers" endpoint (one Stripe retrieve+modify per ONLINE subscriber is too slow for a request). Dispatched via transaction.on_commit; results are reported through structured logs and the Celery result |
events.resync_org_subscription_fees | On demand | Dispatched by the vies_service wrappers when a VAT-status change moves an org's effective fee percent; re-applies application_fee_percent on its live Stripe subscriptions (schedule-managed rows are skipped so a pending downgrade isn't dropped) |
The row-walking tasks materialize their id/row lists with list(), never .iterator() — a server-side cursor cannot survive the per-row commits these loops perform under PgBouncer transaction pooling (see #458 and engineering notes). Each row is then re-loaded under select_for_update + select_related(...), with preconditions re-checked inside the lock, so concurrent record_payment calls or webhook updates cannot be clobbered.
Stripe webhooks¶
events/service/stripe_webhooks.py dispatches subscription events to subscription.stripe.checkout:
| Event | Handler |
|---|---|
checkout.session.completed (mode=subscription) | handle_subscription_checkout_completed — looks up the local row via metadata.membership_subscription_id and links stripe_subscription_id from session.subscription (the Stripe Subscription only exists once the session completes). Ticket/series-pass sessions keep their existing path |
customer.subscription.created / .updated / .deleted | sync_subscription_from_stripe |
invoice.paid | record_stripe_payment_from_invoice(..., succeeded=True) |
invoice.payment_failed | record_stripe_payment_from_invoice(..., succeeded=False) |
invoice.payment_action_required | Same as payment_failed: an off-session renewal blocked on SCA/3DS keeps the invoice open with no failure event, so this is the only prompt signal to go PAST_DUE and dun the member (the portal link in the notification is where they complete confirmation) |
charge.refunded | If a MembershipPayment matches the payment_intent_id, delegates to _handle_subscription_refund → subscription.refunds.refund_payment. Only a fully refunded charge flips the row to REFUNDED and triggers the auto-cancel. A partial refund is recorded on the audit fields (refund_amount, refunded_at, stripe_refund_id) while status stays SUCCEEDED — the member keeps the period they partly paid for. amount_refunded is cumulative, so a later top-up refund reaching the full charge falls through to the full-refund branch |
A monotonicity guard in record_stripe_payment_from_invoice protects the ledger against out-of-order delivery: a late payment_failed (Stripe gives no ordering guarantee, and a failed→retried→paid invoice emits both events) never downgrades a payment row already recorded as SUCCEEDED.
The webhook endpoint listens to Connect events (see Billing & VAT for the "platform vs. connected" caveat).
API Surface¶
Member-facing (/api/me/...)¶
| Method | Path | Purpose |
|---|---|---|
| GET | /membership-subscriptions | List the caller's subscriptions across orgs |
| GET | /organizations/{org_id}/subscription | Caller's current non-terminal sub in this org |
| POST | /organizations/{org_id}/subscribe | Start an ONLINE or FREE subscription. Runs the full eligibility gate stack first. ONLINE: returns a hosted Stripe Checkout checkout_url (redirect flow, same as tickets); an abandoned checkout is resumed — re-subscribing returns the still-open session's URL instead of a 400, or mints a fresh session once the old one expired. FREE: checkout_url is null and the returned subscription is already ACTIVE with the membership granted and any originating application settled COMPLETED — the frontend must branch on the null rather than redirecting |
| POST | /organizations/{org_id}/subscription/cancel | Self-cancel; immediate flag |
| POST | /organizations/{org_id}/subscription/change-plan | Self-service plan change (direction inferred from price delta). Refused into PAUSED or sold-out plans |
| POST | /organizations/{org_id}/subscription/revive | Revive own EXPIRED sub within the org's revival window. ONLINE plans only — payment is collected by Stripe via the returned checkout_url. OFFLINE revival is staff-only (members must never self-record money); members get a 400 directing them to the organization |
| POST | /organizations/{org_id}/billing-portal | Stripe Customer Portal session URL. return_url validated as HttpUrl |
Staff-facing (/api/organization-admin/{slug}/...)¶
Guarded by OrganizationPermission("manage_subscriptions").
| Method | Path | Purpose |
|---|---|---|
| GET | /subscriptions/metrics | Per-org MRR / churn / status breakdown |
| GET / POST | /tiers/{tier_id}/plans | List / create plans on a tier |
| PATCH | /plans/{plan_id} | Edit plan (currency edits refused with active subs) |
| POST | /plans/{plan_id}/archive | is_active = False |
| DELETE | /plans/{plan_id} | Hard-delete (ProtectedError if subs reference it) |
| POST | /plans/{plan_id}/migrate-subscribers | Force-migrate non-terminal subs to the plan's current price |
| GET | /subscriptions | Paginated, searchable list of all subs in the org |
| GET | /subscriptions/{sub_id} | Single sub |
| POST | /subscriptions | Create OFFLINE sub on behalf of a user (refuses ONLINE plans — the member must subscribe themselves) |
| POST | /subscriptions/{sub_id}/payments | Record OFFLINE payment (refuses ONLINE) |
| POST | /subscriptions/{sub_id}/cancel / pause / resume / revive | Lifecycle ops; route ONLINE through Stripe service |
| POST | /payments/{payment_id}/refund | Mark payment REFUNDED. If full refund of current period → auto-cancel |
Notifications¶
Seven new types (notifications/enums.py), each with email, in-app, and Telegram templates under notifications/templates/notifications/{email,in_app,telegram}/:
SUBSCRIPTION_RENEWAL_SUCCEEDED— true renewals only (prior_status ∈ {ACTIVE, PAST_DUE}). Suppressed for first payment and revival payments.SUBSCRIPTION_PAYMENT_FAILED— ONLINE:invoice.payment_failedwebhook. OFFLINE:ACTIVE → PAST_DUEin the beat task.SUBSCRIPTION_EXPIRED— Terminal transition. Includes a revival CTA URL only when the window applies.SUBSCRIPTION_CANCELLATION_CONFIRMED— Local row transition only. Webhook does not re-dispatch when the local flag was already set.SUBSCRIPTION_RENEWAL_REMINDER— Beat task atperiod_end - 3 days.SUBSCRIPTION_PRICE_MIGRATION_NOTICE— Per affected sub inmigrate_plan_subscribers. Skipped when the subscriber's last payment matched the new price already.SUBSCRIPTION_REVIVAL_CHECKOUT— Staff-initiated ONLINE revival: staff cannot pay on the member's behalf, so the member is emailed the hosted Checkout link to complete the renewal themselves.
Per-type opt-out via UserNotificationPreference. The Phase 4 migration back-fills default-on preferences for the seven new types across all existing users.
Migrations¶
| Migration | Purpose |
|---|---|
0101_memberships_subscriptions_integration | Single squashed schema migration for the whole stack: eligibility-pipeline fields (org/tier questionnaire + approval knobs, application statuses, partial unique index), Phase 2–4 subscription fields (CustomerProfile, Stripe ids, pending_plan, expired_at, history tables) and the sale controls (sales_status, max_subscriptions) |
0102_subscription_renewal_reminder_beat | Data migration: daily events.send_subscription_renewal_reminders periodic task (05:00 UTC, an hour after the expiry sweep) |
0103_subscription_reconcile_beat | Data migration: nightly events.reconcile_stripe_subscriptions periodic task (03:30 UTC, before the 04:00 expiry sweep) |
(The Phase 1 foundation — plan/subscription/payment models, manage_subscriptions permission, events.expire_subscriptions_past_grace beat task — shipped to main earlier and is not part of this branch's migrations.)
Audit¶
HistoricalRecords() on MembershipSubscriptionPlan, MembershipSubscription, MembershipPayment, and CustomerProfile. The admin classes use SimpleHistoryAdmin for a "History" tab with row diffs. History starts at first save after the Phase 4 migration; no backfill. See ADR-0015.
Cross-references¶
- Service Layer — hybrid function/class service pattern this system follows.
- Billing & VAT — Stripe Connect webhook caveat, platform fee model (including subscription platform fees), attendee invoicing (ticket-side only).
- Permissions —
OrganizationPermissionmechanics that the staff subscription endpoints inherit. - Notifications — dispatcher and channel selection used by the seven subscription notification types.
- ADRs: 0012, 0013, 0014, 0015.