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, give notice to the gym (recorded by staff), request immediate cancel+refund, self-serve change plan (flag member-plan-change). |
| Owner / Admin | CRUD plans, enroll members (skip payment), force-cancel, freeze, adjust credits, approve/reject cancellation requests, org-initiated plan change (flag admin-plan-change), manual desk charges + debt collection (flag admin-card-on-file, see payments/). |
| 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). - Period-end cancellation from a member’s notice, recorded by staff (no self-serve route on web or mobile; not reversible).
- Immediate-cancel-with-refund request workflow (
cancellation_requeststable) with owner approve/reject. - Auto-created
cancellation_reviewtasks so cancellations are never invisible. - Plan change (FIT-254 §4): staff-initiated (upsell) and member self-serve switching between
subscription-type plans, with day-based proration on upgrades, boundary-only downgrades, and a booking-entitlement sweep so quota can never be banked across a change. Seebehavior.md’s Plan change and Booking-entitlement sweep sections.
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’s notice, recorded by staff, no refund)
POST /organizations/:orgId/subscriptions/:id/cancel { initiator: 'member_request' }→memberCancelAtPeriodEnd(subscriptions.service.ts).- Sets
cancelAtPeriodEnd=true, records reason, notice date and actor; the effective date is one month after the notice. - No
cancellation_requestsrow. FirescancellationScheduledemail + low-prioritycancellation_reviewtask. - Members have no self-serve route for this — neither the web app nor the mobile app offers a cancel action, and there is no route to un-schedule a recorded notice. The member sees the end date on their payments page.
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). |
Feature flags (FIT-254)
All four are PostHog flags evaluated per-org (organization group via EventTrackingService.isFeatureEnabled), default OFF, fail-closed — an unset/unreachable/false read behaves exactly like “off” (server 403s before any DB/provider work; web hides the affected UI). None loosen any existing behavior when off; they only add new surfaces. See ADR-0016 for the full rollout rationale.
| Flag | Key | Gates | Fail mode |
|---|---|---|---|
| Admin card-on-file | admin-card-on-file | Card-on-file visibility, registration, manual (desk) charges, debt collection — see payments/behavior.md. | Fail-closed (=== true required) |
| Admin plan change | admin-plan-change | Org-initiated plan change (all three immediate variants + scheduled), preview, cancel-scheduled — staff routes. | Fail-closed |
| Member plan change | member-plan-change | Member self-serve change-plan, preview, cancel-own-schedule. | Fail-closed |
| Booking-entitlement sweep | booking-entitlement-sweep | C3 (booking-horizon guard) + C4 (sweep on subscription end: immediate cancel, period-end cron, both debt paths). Does not gate the sweep engine itself when invoked from inside a plan-change flow — that’s inseparable from admin-plan-change/member-plan-change, since a plan change without its sweep is the abuse hole the sweep exists to close. | Fail-closed |
Intended rollout order: A (admin-card-on-file, pure addition) → C (booking-entitlement-sweep, hardening) → B org-initiated (admin-plan-change) → B member self-serve (member-plan-change, needs A live for card-registration CTAs and C live for honest booking-horizon behavior). admin-card-on-file’s CHARGE_VERIFIED_PROVIDERS allowlist (cardcom, meshulam, test) is a separate, code-level gate on top — not a PostHog flag — see payments/behavior.md.
Two adjacent fixes ship unflagged (failing toward correctness/safety, not a feature to toggle): the renew fix (C1, subscriptions.service.ts:230) and the payment-method delete guard (C2). See behavior.md’s “Renew (member/staff-triggered) — C1 fix” section.
Related features
forms/— a published compliance template linked to a plan (forms.plan_id, e.g.plan_regulationsתקנון or a youth plan’s parental consent) gatespurchase()behind a signed instance: 409form_signature_requiredwith a lazily-issued signable instance. Member-facing plan responses carryformRequirements; staffenrollMemberbypasses but auto-issues the paperwork. Seeforms/behavior.md§9.payments/— owns the actual money calls, webhooks, and refund task lifecycle.bookings/— callssubscriptionsService.deductCredit / refundCredit(subscriptions.service.ts:721); hosts the C3 booking-horizon guard and shares the booking-entitlement sweep engine with plan change.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). Plan change (FIT-254 §4) and the booking-entitlement sweep (§4.6/§5) are implemented and merged but gated OFF by default behind the flags above pending staged rollout.
Gaps
- Plan change proration is v1-scoped: day-based unused-credit on upgrades only; no credit balances, no refunds — a
dueNow < ₪1result simply defers to the boundary rather than paying money back. See ADR-0016. - Pause limits not enforced — a sub can stay
pausedindefinitely; the resume extendscurrentPeriodEndby the full paused duration with no cap. Freeze is also exempt from the booking-entitlement sweep in v1 (seebehavior.md). - No grandfathered pricing — updating
plan.priceInCentsaffects all future renewals of existing subs (the renewal cron readsplan.price_in_centslive). Applies equally to a scheduled plan change: the live price at the boundary is charged, not the price shown at scheduling time. 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). - W4 UI inconsistency — the staff scheduled-change badge and the member scheduled-change banner show two different dates (notice time vs. effective time); see
behavior.md. Slated for a W7 fix. - Sweep-triggered waitlist-promotion emails bypass notification-prefs opt-out — a documented DI trade-off, W7 audit item.
- Migration
0090(the three plan-change columns) has not yet been applied to any real environment — human to-do, seedata-model.md.