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

Subscriptions & Plans — Behavior

Plan CRUD

PlansController (apps/api/src/plans/plans.controller.ts) — all routes @Controller('organizations/:orgId/plans').

RouteMethodGuardNotes
/POSTowner/admin + @RequiresFeature('membership_plans')Validates type constraints: subscription needs interval; class_pack needs classCredits. class_pack also requires the class_packs feature (service check at plans.service.ts:62).
/GETactive memberLists plans; default filter isActive=true. Cache-Control: private, max-age=120, stale-while-revalidate=600.
/:idGETactive member
/:idPATCHowner/admin + @RequiresFeaturePartial update; updates plan.currency if passed.
/:idDELETEowner/admin + @RequiresFeatureSoft-delete via isActive=false.
/:id/purchasePOSTactive memberDrives the member checkout. Rejects plan.type='course' (must go through /public/courses/:id/checkout).

Purchase flow (PlansService.purchaseplans.service.ts:200)

  1. Load the plan; reject if inactive, course-type, or missing.
  2. Look up any existing subscription for (membershipId, planId) with status in (active, pending, past_due, debt).
    • active → 409 You already have an active subscription for this plan.
    • past_due | debt → 409 You already have a subscription with an outstanding balance.
    • pending → reuse the row (generates a fresh payment page for it).
    • none → create a new pending (or active for free plans).
  3. Emit payment.checkout_started observability event.
  4. Free plan: return immediately with { subscription } (subscription.status='active').
  5. Paid plan: build ClientInfo from member profile (taxId, address, etc. for invoicing — plans.service.ts:321), call PaymentService.createHostedPayment.
  6. On payment-page creation failure, delete the pending subscription row (plans.service.ts:362) so retries don’t accumulate dead rows.
  7. Return { subscription, paymentPageUrl }.

The hosted-page URL is followed by the client; activation happens via webhook (see payments/behavior.md).

Subscription lifecycle endpoints

SubscriptionsController (apps/api/src/subscriptions/subscriptions.controller.ts).

RouteMethodRoleEffect
/subscriptions/myGETmemberLists own subs (excluding plan.type='course').
/members/:id/subscriptionsGETowner/admin/coachRead a member’s subs.
/members/:id/enrollPOSTowner/adminenrollMember — creates an active sub with skipPayment=true.
/subscriptions/:id/renewPOSTowner/admin or sub ownerCharges via stored payment method synchronously; updates period.
/subscriptions/:id/cancelPOSTowner/admincancelSubscription — immediate; tries paymentService.cancelRecurring on the provider if providerSubscriptionId set.
/subscriptions/my/:id/cancel-at-period-endPOSTsub ownerSets cancelAtPeriodEnd=true. Requires reason.
/subscriptions/my/:id/resumePOSTsub ownerReverses the period-end flag; only while still active and not yet swept.
/subscriptions/:id/freezePOSTowner/adminactive → paused; stamps pausedAt.
/subscriptions/:id/resumePOSTowner/adminpaused → active; extends currentPeriodEnd by (now - pausedAt).
/subscriptions/:id/adjust-creditsPOSTowner/adminremainingCredits += amount (clamped ≥ 0). Refuses unlimited plans.

Renewal cron

RecurringChargeService.handleRecurringCharges — daily 02:00 UTC (recurring-charge.service.ts:46).

See payments/behavior.md for the full flow. Subscription-side outcomes:

OutcomeSub updateMembership updateEmail
Successstatus=active, period advanced, attempts=0, credits refilledpayment_status='current'none (receipt only via initial charge path)
Attempt 1–2 failstatus=past_due, attempts++, `nextChargeDate=now+[37]d`payment_status='past_due'
Attempt 3 failstatus=debt, debt_amount_in_cents += price, debtSince=now, nextChargeDate=NULLpayment_status='debt'debtWarningHtml

SELECT … FOR UPDATE OF s SKIP LOCKED prevents double-handling under multi-worker deploy.

Cancellation cron

CancellationCronService (apps/api/src/subscriptions/cancellation-cron.service.ts) — daily 02:30 UTC, runs after the renewal cron so a sub that’s scheduled to cancel and also renewing the same morning isn’t double-handled.

sweepDueCancellations() picks rows WHERE cancelAtPeriodEnd=true AND status='active' AND currentPeriodEnd <= now flips each to status='cancelled', cancelledAt=now fires cancellationRequestApproved(refundIssued=false) — the "your sub has ended" receipt emits payment.subscription_cancelled { source: 'period_end_cron' }

Credits

SubscriptionsService.deductCredit / refundCredit / adjustCredits (subscriptions.service.ts:721).

  • deductCredit(membershipId) — finds active sub. If plan has no classCredits (unlimited), no-op. Otherwise remainingCredits--; throws if zero.
  • refundCredit(membershipId) — finds active or paused sub. Same no-op rule.
  • adjustCredits — owner/admin only. Math.max(0, remaining + amount).

Bookings call these; the implementation lives here.

Permissions matrix

ActionRoleSource
Create planowner/adminplans.service.ts:49
Update planowner/adminplans.service.ts:134
Delete planowner/adminplans.service.ts:181
Purchaseactive memberimplicit (requireMembership)
Enroll memberowner/adminsubscriptions.service.ts:175
Renew subsub owner OR owner/adminsubscriptions.service.ts:232
Cancel sub (admin)owner/adminsubscriptions.service.ts:302
Cancel-at-period-endsub ownersubscriptions.service.ts:486 (requireMemberOwnedSubscription)
Freeze / resume / adjust-creditsowner/adminsubscriptions.service.ts:628, :675, :785
Submit cancellation requestsub ownercancellation-requests.service.ts:75
Approve / reject cancellation requestowner/admincancellation-requests.service.ts:362

Side effects per action

TriggerSide effects
Plan price changeAffects all future renewals of existing subs immediately (renewal cron reads plan.price_in_cents live).
Plan delete (soft)Existing subs continue charging on the old (now isActive=false) plan. New purchases blocked.
enrollMemberSub active with no payment; skipPayment=true does NOT touch payment provider. Useful for comp memberships.
freezeSubscriptionBookings refuse credits until resumed. Charge cron skips paused subs (selector requires status IN ('active','past_due')).
Cancellation request created2 emails + 1 high-urgency task.
Cancellation request approved with refundSub canceled, refund flow runs, member email, observability payment.cancellation_request_approved. Refund task created if provider is manual.

Edge cases

CaseBehaviour
Member purchases free plan they already have active409 ConflictException (plans.service.ts:265).
Member retries an abandoned checkoutPending sub reused; fresh payment page issued; no duplicate subscriptions row.
Webhook arrives for already-active subActivate transaction short-circuits; period not re-advanced if status was already active and the transition pending → active isn’t taken.
Owner cancels a paused subAllowed; transitions paused → cancelled.
Owner cancels a sub already cancelled400 Subscription is already cancelled.
Member cancels twiceSecond request — guard at cancellation-requests.service.ts:84 rejects with 400 request is already pending.
Adjust credits to negativeClamped to 0 (subscriptions.service.ts:815).
Adjust credits on unlimited plan400 This plan has unlimited credits.
Sub stuck in pending (abandoned checkout)No automated cleanup today. Manual DB delete or the next purchase attempt that reuses the row.
Renewal cron hits a sub with no payment_method_idSelector excludes it (AND payment_method_id IS NOT NULLrecurring-charge.service.ts:71). The sub will quietly never renew.