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:
| Persona | Capability |
|---|---|
| Member | Browse plans, purchase, view own subscriptions, self-cancel at period end, request immediate cancel+refund. |
| Owner / Admin | CRUD plans, enroll members (skip payment), force-cancel, freeze, adjust credits, approve/reject cancellation requests. |
| Coach | Read a member’s subscriptions (no mutation). |
Capabilities
- Four plan types via
plan_typeenum: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
currentPeriodEndby paused duration). - Member self-cancel at period end (reversible until the daily cron sweeps).
- Immediate-cancel-with-refund request workflow (
cancellation_requeststable) with owner approve/reject. - Auto-created
cancellation_reviewtasks so cancellations are never invisible.
Plan types
type | Lifecycle | Credits | Renewal |
|---|---|---|---|
subscription | Recurring; currentPeriodEnd advances each cycle. | Optional classCredits refills per period; null = unlimited. | Auto-charge via RecurringChargeService. |
class_pack | One-shot purchase. remainingCredits = plan.classCredits. | Decrements per booking. | No auto-renew; member buys again. |
drop_in | One-shot, single use. | remainingCredits = 1. | None. |
course | One-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.
| Transition | Driver |
|---|---|
_ → pending | SubscriptionsService.createSubscription for paid plans (subscriptions.service.ts:140). |
_ → active | createSubscription for free plans or admin enroll (skipPayment=true). |
pending → active | Payment webhook (webhook-processing.service.ts:268) or verifyAndActivateReturn. |
pending → cancelled | First-charge failed (webhook-processing.service.ts:379). |
active → past_due | Renewal cron fails, attempts < 3 (recurring-charge.service.ts:262). |
past_due → active | Retry charge succeeds. |
past_due → debt | Third consecutive renewal failure. |
active → paused | Admin freezeSubscription (subscriptions.service.ts:617). |
paused → active | Admin resumeSubscription — currentPeriodEnd extended by paused duration. |
active → cancelled | Admin 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_requestsrow. FirescancellationScheduledemail + low-prioritycancellation_reviewtask. - Reversible via
POST /subscriptions/my/:id/resumeuntil the cron sweeps.
2. Immediate cancel + refund (member request, owner approval)
POST /organizations/:orgId/cancellation-requests→ creates acancellation_requestsrow (status='pending') (cancellation-requests.service.ts:62).- Fires owner-notification email + member-confirmation email + urgent
cancellation_reviewtask. - Owner:
POST /cancellation-requests/:id/approveor…/reject. - Approve flow (
cancellation-requests.service.ts:253):- Sub cancelled immediately.
- If refund requested, the most recent completed
chargetxn is found andPaymentService.refundis invoked — capability-aware (automatic ⇒ instant, manual ⇒ task). - Request →
approved;refund_task_idset when manual.
Permissions
| Action | Required role |
|---|---|
| Create / edit / delete plan | owner or admin (plans.service.ts:49) — also needs membership_plans feature on tier. |
| Purchase a plan | any active member (member-facing). |
Enroll a member in a plan (skipPayment) | owner or admin. |
| Freeze, resume, force-cancel, adjust-credits | owner or admin. |
| Self-cancel at period end / resume | the subscription’s owner only. |
| Approve / reject cancellation request | owner or admin. |
| Read another member’s subscriptions | owner, admin, or coach (subscriptions.controller.ts:108). |
Related features
payments/— owns the actual money calls, webhooks, and refund task lifecycle.bookings/— callssubscriptionsService.deductCredit / refundCredit(subscriptions.service.ts:721).courses/— course plans useplan.type='course'and link viaplan.program_id; the dedicated checkout service bypassesplans.purchase.platform-tiers/—automated_billing(recurring) andclass_packsfeatures 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
pausedindefinitely; the resume extendscurrentPeriodEndby the full paused duration with no cap. - No grandfathered pricing — updating
plan.priceInCentsaffects all future renewals of existing subs (the renewal cron readsplan.price_in_centslive). cancelAtPeriodEndrace — between the daily 02:30 sweep and a manual admin cancel, two flips can land on the same row. Idempotent in outcome but emits twopayment.subscription_cancelledevents.- FIT-136 — failed renewal retry tuning (see
payments/README.md).