Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSubscriptions PlansSubscriptions & Plans

Subscriptions & Plans

Membership plans, recurring subscriptions, and the cancellation-request workflow.

What & why

A plan is a sellable offering (subscription, class pack, drop-in, or course) defined per-org. A subscription is a member’s instance of a plan with a lifecycle (pending → active → past_due/debt | paused | cancelled). Plans live in payments.ts schema but are exposed to staff via the dedicated plans/ API module and consumed by subscriptions/, bookings/, and courses/.

Persona impact:

PersonaCapability
MemberBrowse plans, purchase, view own subscriptions, self-cancel at period end, request immediate cancel+refund.
Owner / AdminCRUD plans, enroll members (skip payment), force-cancel, freeze, adjust credits, approve/reject cancellation requests.
CoachRead a member’s subscriptions (no mutation).

Capabilities

  • Four plan types via plan_type enum: subscription | class_pack | drop_in | course.
  • Plan intervals: weekly | monthly | quarterly | yearly.
  • Class-credit tracking (remainingCredits) with deduct/refund hooks for bookings.
  • Booking-frequency caps on a plan: max_bookings_per_day, max_bookings_per_week, allow_overlapping_bookings.
  • Freeze / resume (pause + extend currentPeriodEnd by paused duration).
  • Member self-cancel at period end (reversible until the daily cron sweeps).
  • Immediate-cancel-with-refund request workflow (cancellation_requests table) with owner approve/reject.
  • Auto-created cancellation_review tasks so cancellations are never invisible.

Plan types

typeLifecycleCreditsRenewal
subscriptionRecurring; currentPeriodEnd advances each cycle.Optional classCredits refills per period; null = unlimited.Auto-charge via RecurringChargeService.
class_packOne-shot purchase. remainingCredits = plan.classCredits.Decrements per booking.No auto-renew; member buys again.
drop_inOne-shot, single use.remainingCredits = 1.None.
courseOne-time digital good.None.Owns a separate course_entitlements row. Sold via courses/ flow, not /plans/:id/purchase (rejected at plans.service.ts:245).

Subscription status machine

subscription_status enum — pending | active | past_due | cancelled | paused | debt.

TransitionDriver
_ → pendingSubscriptionsService.createSubscription for paid plans (subscriptions.service.ts:140).
_ → activecreateSubscription for free plans or admin enroll (skipPayment=true).
pending → activePayment webhook (webhook-processing.service.ts:268) or verifyAndActivateReturn.
pending → cancelledFirst-charge failed (webhook-processing.service.ts:379).
active → past_dueRenewal cron fails, attempts < 3 (recurring-charge.service.ts:262).
past_due → activeRetry charge succeeds.
past_due → debtThird consecutive renewal failure.
active → pausedAdmin freezeSubscription (subscriptions.service.ts:617).
paused → activeAdmin resumeSubscriptioncurrentPeriodEnd extended by paused duration.
active → cancelledAdmin cancelSubscription, member period-end cron, or cancellation-request approval.

The cancelAtPeriodEnd boolean is a flag on an active row, not a status — the sub stays active (and the member still has access) until the daily cron at 02:30 UTC sweeps and flips it to cancelled (cancellation-cron.service.ts:19).

Cancellation workflows

Two distinct paths, modelled separately:

1. Cancel at period end (member-initiated, no refund)

  • POST /organizations/:orgId/subscriptions/my/:id/cancel-at-period-end { reason }memberCancelAtPeriodEnd (subscriptions.service.ts:317).
  • Sets cancelAtPeriodEnd=true, records reason and actor.
  • No cancellation_requests row. Fires cancellationScheduled email + low-priority cancellation_review task.
  • Reversible via POST /subscriptions/my/:id/resume until the cron sweeps.

2. Immediate cancel + refund (member request, owner approval)

  • POST /organizations/:orgId/cancellation-requests → creates a cancellation_requests row (status='pending') (cancellation-requests.service.ts:62).
  • Fires owner-notification email + member-confirmation email + urgent cancellation_review task.
  • Owner: POST /cancellation-requests/:id/approve or …/reject.
  • Approve flow (cancellation-requests.service.ts:253):
    1. Sub cancelled immediately.
    2. If refund requested, the most recent completed charge txn is found and PaymentService.refund is invoked — capability-aware (automatic ⇒ instant, manual ⇒ task).
    3. Request → approved; refund_task_id set when manual.

Permissions

ActionRequired role
Create / edit / delete planowner or admin (plans.service.ts:49) — also needs membership_plans feature on tier.
Purchase a planany active member (member-facing).
Enroll a member in a plan (skipPayment)owner or admin.
Freeze, resume, force-cancel, adjust-creditsowner or admin.
Self-cancel at period end / resumethe subscription’s owner only.
Approve / reject cancellation requestowner or admin.
Read another member’s subscriptionsowner, admin, or coach (subscriptions.controller.ts:108).
  • payments/ — owns the actual money calls, webhooks, and refund task lifecycle.
  • bookings/ — calls subscriptionsService.deductCredit / refundCredit (subscriptions.service.ts:721).
  • courses/ — course plans use plan.type='course' and link via plan.program_id; the dedicated checkout service bypasses plans.purchase.
  • platform-tiers/automated_billing (recurring) and class_packs features are tier-gated.

Status

Production. Recurring billing has been running since the Cardcom production terminal rollout (see docs/_archive/plans/cardcom-production-terminal.md).

Gaps

  • No proration on mid-cycle plan changes. Switching plans means cancelling + re-purchasing.
  • Pause limits not enforced — a sub can stay paused indefinitely; the resume extends currentPeriodEnd by the full paused duration with no cap.
  • No grandfathered pricing — updating plan.priceInCents affects all future renewals of existing subs (the renewal cron reads plan.price_in_cents live).
  • cancelAtPeriodEnd race — between the daily 02:30 sweep and a manual admin cancel, two flips can land on the same row. Idempotent in outcome but emits two payment.subscription_cancelled events.
  • FIT-136 — failed renewal retry tuning (see payments/README.md).