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, give notice to the gym (recorded by staff), request immediate cancel+refund, self-serve change plan (flag member-plan-change).
Owner / AdminCRUD 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/).
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).
  • 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_requests table) with owner approve/reject.
  • Auto-created cancellation_review tasks 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. See behavior.md’s Plan change and Booking-entitlement sweep sections.

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’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_requests row. Fires cancellationScheduled email + low-priority cancellation_review task.
  • 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 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).

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.

FlagKeyGatesFail mode
Admin card-on-fileadmin-card-on-fileCard-on-file visibility, registration, manual (desk) charges, debt collection — see payments/behavior.md.Fail-closed (=== true required)
Admin plan changeadmin-plan-changeOrg-initiated plan change (all three immediate variants + scheduled), preview, cancel-scheduled — staff routes.Fail-closed
Member plan changemember-plan-changeMember self-serve change-plan, preview, cancel-own-schedule.Fail-closed
Booking-entitlement sweepbooking-entitlement-sweepC3 (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.

  • forms/ — a published compliance template linked to a plan (forms.plan_id, e.g. plan_regulations תקנון or a youth plan’s parental consent) gates purchase() behind a signed instance: 409 form_signature_required with a lazily-issued signable instance. Member-facing plan responses carry formRequirements; staff enrollMember bypasses but auto-issues the paperwork. See forms/behavior.md §9.
  • payments/ — owns the actual money calls, webhooks, and refund task lifecycle.
  • bookings/ — calls subscriptionsService.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 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). 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 < ₪1 result simply defers to the boundary rather than paying money back. See ADR-0016.
  • Pause limits not enforced — a sub can stay paused indefinitely; the resume extends currentPeriodEnd by the full paused duration with no cap. Freeze is also exempt from the booking-entitlement sweep in v1 (see behavior.md).
  • No grandfathered pricing — updating plan.priceInCents affects all future renewals of existing subs (the renewal cron reads plan.price_in_cents live). Applies equally to a scheduled plan change: the live price at the boundary is charged, not the price shown at scheduling time.
  • 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).
  • 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, see data-model.md.