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:
| Persona | Capability |
|---|---|
| Member | Browse 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 / Admin | CRUD 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/). |
| 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. - Trial plans: the plan editor’s Trial workout kind is a
drop_inwithis_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 409trial_already_usedto 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 (400trial_plan_in_use). See Trials › Trial plans. - Freeze / resume (pause + extend
currentPeriodEndby paused duration). - Member cancellation request for recurring subscriptions, with a required structured reason code and optional free text except when
otheris selected. A request opens an owner task and issues any published cancellation form, but leaves billing, access, dates, andcancelAtPeriodEndunchanged. - 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
requesttonotice. Presale withdrawal/opening-day failure also open cases. Notice cases retain the existing SLA, escalation, outcome, and withdrawal lifecycle. Seebehavior.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. Seebehavior.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
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. A trial plan (is_trial, “Trial workout”) is a drop-in. | remainingCredits = 1. Trial drop-ins may expire expiresInDays after activation. | 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 | scheduled. past_due is legacy: no billing path writes it any more.
| 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 → debt | First 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 → active | A retry or any full payment of the debt — period unchanged, next charge on the normal date. |
debt → cancelled | Day N of the gym’s grace days, still unpaid — cancellation_reason='non_payment', debt kept (lifecycle job). |
cancelled → active | Full payment of a non-payment debt restores the period it covered. |
active → cancelled | Lifecycle job: pack/drop-in validity over (expired), set-period gift over (gift_ended), paid card-less term over (term_ended). |
scheduled → debt | Presale first charge declined or card unusable — the presale starts that day. |
active → paused | Admin freezeSubscription (subscriptions.service.ts:617). |
paused → active | Admin resumeSubscription — currentPeriodEnd extended by paused duration. |
active → cancelled | Admin 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;reasonis required whenreasonCode='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_reviewtask withretentionCaseKind='request'. - Issues any published org-wide cancellation form after the request commits and returns
cancellationFormInstanceIdso mobile can open it immediately. Does not setcancelAtPeriodEnd, 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/resumeendpoint. Theresume_cancelmember 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 fromrequesttonotice. - 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. Seepayments/behavior.md’s refund-flow section.
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. |
| Request cancellation | the subscription’s owner only; request semantics above. |
| Issue member cancellation / work or withdraw a retention case | owner or admin, subscriptions/manage. |
| Read another member’s subscriptions | owner, 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.
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). 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.priceInCentsaffects ordinary future renewals. A scheduled transition instead charges its persisted accepted price and currency at the boundary. 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). - Sweep-triggered waitlist-promotion emails bypass notification-prefs opt-out — a documented DI trade-off.