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

Subscriptions & Plans

Membership plans, recurring subscriptions, and the cancellation request-to-notice 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 → debt → cancelled for non-payment, or 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/.

A member action is a request; staff issuance creates the legal cancellation notice. The mobile app records a timestamped request, opens an owner-assigned retention task, and immediately routes the member to any published cancellation form for signature. It does not stop billing or schedule an end date. Owner/admin staff issue the cancellation from that task. Issuance promotes the same retention case to notice, preserves the member’s original timestamp and reason, computes the statutory effective date, and starts the notification/settlement lifecycle without issuing duplicate paperwork when the request form was already signed. There is no separate cancellation_requests table or approve/reject state machine.

Persona impact:

PersonaCapability
MemberBrowse plans, purchase, view own subscriptions, request cancellation (required reason code; explanation required for other), sign the request form, withdraw a pending request or live issued notice, withdraw a presale before opening day, self-serve change plan including a presale plan swap within its own plan group.
Owner / AdminCRUD plans, enroll members (skip payment), issue a member-requested cancellation, force-cancel for the business, freeze, adjust credits, work retention cases opened by a request/notice/presale withdrawal/opening-day decline, org-initiated plan change, manual desk charges + debt collection (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.
  • Trial plans: the plan editor’s Trial workout kind is a drop_in with is_trial (isTrial) — every booking paid with it is a trial on the coach’s roster. It carries an optional use-within window (expiresInDays) and one per person (trialOncePerPerson, default on): a member self-serve purchase is refused with 409 trial_already_used to anyone who already held a trial plan of the org or has an unpaid checkout of another one; staff are never refused. A trial plan that was sold cannot be un-flagged (400 trial_plan_in_use). See Trials › Trial plans.
  • Freeze / resume (pause + extend currentPeriodEnd by paused duration).
  • Member cancellation request for recurring subscriptions, with a required structured reason code and optional free text except when other is selected. A request opens an owner task and issues any published cancellation form, but leaves billing, access, dates, and cancelAtPeriodEnd unchanged.
  • Staff-issued member cancellation notice at period end (member-reversible until the daily cron sweeps), anchored to the original request timestamp when one exists.
  • Retention case: a request opens a staff obligation; staff issuance promotes that same task from request to notice. Presale withdrawal/opening-day failure also open cases. Notice cases retain the existing SLA, escalation, outcome, and withdrawal lifecycle. See behavior.md’s Retention case section.
  • Temporary retention pricing: staff may offer a percentage off the plan price for a bounded number of calendar months on Taikan-managed billing only. Renewal pricing automatically returns to the plan’s ordinary pricing at expiry; permanent/manual desk prices remain a separate explicit option.
  • Presale plan swap: a scheduled (FIT-287 presale) subscription can move to another plan in its own plan group, on the same row — no checkout, nothing charged now, seat/buyer-order/token/opening-day all preserved. See behavior.md’s Presale swap section.
  • Plan transition (TKN-310): a member or authorized staff member persists one subscription target and its accepted price/currency for the next billing boundary. Upgrades charge before target terms apply; downgrades and laterals apply before their boundary charge. An eligible one-window cycle may be explicitly advanced, which forfeits its remainder and applies a pending target immediately. See ADR-0019 and Plan transitions.

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. A trial plan (is_trial, “Trial workout”) is a drop-in.remainingCredits = 1. Trial drop-ins may expire expiresInDays after activation.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 | scheduled. past_due is legacy: no billing path writes it any more.

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 → debtFirst renewal decline, or the card expired/missing at charge time (charge jobs); cash coverage lapsed or a pay-later due date passed (lifecycle job).
debt → activeA retry or any full payment of the debt — period unchanged, next charge on the normal date.
debt → cancelledDay N of the gym’s grace days, still unpaid — cancellation_reason='non_payment', debt kept (lifecycle job).
cancelled → activeFull payment of a non-payment debt restores the period it covered.
active → cancelledLifecycle job: pack/drop-in validity over (expired), set-period gift over (gift_ended), paid card-less term over (term_ended).
scheduled → debtPresale first charge declined or card unusable — the presale starts that day.
active → pausedAdmin freezeSubscription (subscriptions.service.ts:617).
paused → activeAdmin resumeSubscription — currentPeriodEnd extended by paused duration.
active → cancelledAdmin cancelSubscription or the period-end cron after staff-issued member cancellation.

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 model

Three distinct actions:

1. Member cancellation request (no cancellation yet)

  • POST /organizations/:orgId/subscriptions/my/:id/cancellation-request { reasonCode, reason? } → memberRequestCancellation; reason is required when reasonCode='other'.
  • Subscription owner only; accepted for live recurring subscriptions, including externally billed ones. Class packs/drop-ins/courses and scheduled/pending/terminal rows are rejected.
  • Stores the request timestamp, member actor, required reason code, optional reason text, and request provenance. Opens an urgent owner-assigned cancellation_review task with retentionCaseKind='request'.
  • Issues any published org-wide cancellation form after the request commits and returns cancellationFormInstanceId so mobile can open it immediately. Does not set cancelAtPeriodEnd, notice/effective dates, stop recurring billing, clear a scheduled plan change, or send cancellation confirmation. Duplicate pending requests return 409.
  • The member can immediately choose I want to stay through the existing POST /organizations/:orgId/subscriptions/my/:id/resume endpoint. The resume_cancel member action covers both pending requests and live issued notices, including external billing. It closes the request as saved, completes its task, clears cancellation fields, and revokes only unsigned paperwork without changing provider billing. A repeated call after the request is closed returns the existing 400 “not scheduled” refusal.

2. Staff issues the member-requested cancellation

  • POST /organizations/:orgId/subscriptions/:id/cancel { initiator: 'member_request', noticeChannel, noticeAt?, memberRequestedDate?, reason?, reasonCode? } → issueMemberCancellation.
  • For a pending mobile request, the service ignores replacement reason/date/channel values and uses the member’s original timestamp/reason with channel mobile. The dashboard locks that task to this issuance path.
  • Sets cancelAtPeriodEnd=true, computes the statutory effective date, sends confirmation, reuses an unsigned request form when present, avoids a second form when the request form was already signed, and promotes the same retention task from request to notice.
  • Immediate mode is invalid for member_request. A staff-recorded offline notice uses the same issuance method and legal calculations.

3. Staff immediate cancel (business-initiated)

  • POST /organizations/:orgId/subscriptions/:id/cancel { initiator: 'business', reason, refund? } — owner/admin only, ends the subscription immediately.
  • Refund, if requested, runs through PaymentService.refund — capability-aware (automatic ⇒ instant, manual ⇒ task) and resolved against the transaction’s own provider. See payments/behavior.md’s refund-flow section.

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.
Request cancellationthe subscription’s owner only; request semantics above.
Issue member cancellation / work or withdraw a retention caseowner or admin, subscriptions/manage.
Read another member’s subscriptionsowner, admin, or coach (subscriptions.controller.ts:108).

Gating (FIT-254)

Card-on-file (visibility, registration, manual desk charges, debt collection), org-initiated and member self-serve plan change (including preview and cancel-scheduled), and the booking-entitlement sweep’s hardening call sites (C3 booking-horizon guard, C4 sweep on subscription end) are available to every org, gated only by role and ownership. CHARGE_VERIFIED_PROVIDERS (cardcom, meshulam, test) is a code-level allowlist on top of manual and plan-change charges — see payments/behavior.md.

Boundary fulfillment, accepted price snapshots, and cycle-advance eligibility (TKN-310) are one correctness model and do not vary by organization.

Two adjacent fixes fail toward correctness/safety: 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). TKN-310 plan transitions follow ADR-0019: target terms are committed at scheduling and fulfilled at the boundary. The notice model, retention cases, cancellation-form prompting, provider standing-order cancellation and settlement are always-on. Presale swap and the observability family remain unchanged. Israeli counsel review of the member contract and applicable legal disclosures is a launch prerequisite, not an organization rollout switch.

Gaps

  • No grandfathering outside a scheduled transition — updating plan.priceInCents affects ordinary future renewals. A scheduled transition instead charges its persisted accepted price and currency at the boundary.
  • 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).
  • Sweep-triggered waitlist-promotion emails bypass notification-prefs opt-out — a documented DI trade-off.