Subscriptions & Plans — Behavior
Plan CRUD
PlansController (apps/api/src/plans/plans.controller.ts) — all routes @Controller('organizations/:orgId/plans').
| Route | Method | Guard | Notes |
|---|---|---|---|
/ | POST | owner/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). |
/ | GET | active member | Lists plans; default filter isActive=true. Cache-Control: private, max-age=120, stale-while-revalidate=600. |
/:id | GET | active member | |
/:id | PATCH | owner/admin + @RequiresFeature | Partial update; updates plan.currency if passed. |
/:id | DELETE | owner/admin + @RequiresFeature | Soft-delete via isActive=false. |
/:id/purchase | POST | active member | Drives the member checkout. Rejects plan.type='course' (must go through /public/courses/:id/checkout). |
Purchase flow (PlansService.purchase — plans.service.ts:200)
- Load the plan; reject if inactive, course-type, or missing.
- Look up any existing subscription for
(membershipId, planId)with status in(active, pending, past_due, debt).active→ 409You already have an active subscription for this plan.past_due | debt→ 409You already have a subscription with an outstanding balance.pending→ reuse the row (generates a fresh payment page for it).- none → create a new
pending(oractivefor free plans).
- Emit
payment.checkout_startedobservability event. - Free plan: return immediately with
{ subscription }(subscription.status='active'). - Paid plan: build
ClientInfofrom member profile (taxId, address, etc. for invoicing —plans.service.ts:321), callPaymentService.createHostedPayment. - On payment-page creation failure, delete the pending subscription row (
plans.service.ts:362) so retries don’t accumulate dead rows. - 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).
| Route | Method | Role | Effect |
|---|---|---|---|
/subscriptions/my | GET | member | Lists own subs (excluding plan.type='course'). |
/members/:id/subscriptions | GET | owner/admin/coach | Read a member’s subs. |
/members/:id/enroll | POST | owner/admin | enrollMember — creates an active sub with skipPayment=true. |
/subscriptions/:id/renew | POST | owner/admin or sub owner | Charges via stored payment method synchronously; updates period. |
/subscriptions/:id/cancel | POST | owner/admin | cancelSubscription — immediate; tries paymentService.cancelRecurring on the provider if providerSubscriptionId set. |
/subscriptions/my/:id/cancel-at-period-end | POST | sub owner | Sets cancelAtPeriodEnd=true. Requires reason. |
/subscriptions/my/:id/resume | POST | sub owner | Reverses the period-end flag; only while still active and not yet swept. |
/subscriptions/:id/freeze | POST | owner/admin | active → paused; stamps pausedAt. |
/subscriptions/:id/resume | POST | owner/admin | paused → active; extends currentPeriodEnd by (now - pausedAt). |
/subscriptions/:id/adjust-credits | POST | owner/admin | remainingCredits += 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:
| Outcome | Sub update | Membership update | |
|---|---|---|---|
| Success | status=active, period advanced, attempts=0, credits refilled | payment_status='current' | none (receipt only via initial charge path) |
| Attempt 1–2 fail | status=past_due, attempts++, `nextChargeDate=now+[3 | 7]d` | payment_status='past_due' |
| Attempt 3 fail | status=debt, debt_amount_in_cents += price, debtSince=now, nextChargeDate=NULL | payment_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 noclassCredits(unlimited), no-op. OtherwiseremainingCredits--; 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
| Action | Role | Source |
|---|---|---|
| Create plan | owner/admin | plans.service.ts:49 |
| Update plan | owner/admin | plans.service.ts:134 |
| Delete plan | owner/admin | plans.service.ts:181 |
| Purchase | active member | implicit (requireMembership) |
| Enroll member | owner/admin | subscriptions.service.ts:175 |
| Renew sub | sub owner OR owner/admin | subscriptions.service.ts:232 |
| Cancel sub (admin) | owner/admin | subscriptions.service.ts:302 |
| Cancel-at-period-end | sub owner | subscriptions.service.ts:486 (requireMemberOwnedSubscription) |
| Freeze / resume / adjust-credits | owner/admin | subscriptions.service.ts:628, :675, :785 |
| Submit cancellation request | sub owner | cancellation-requests.service.ts:75 |
| Approve / reject cancellation request | owner/admin | cancellation-requests.service.ts:362 |
Side effects per action
| Trigger | Side effects |
|---|---|
| Plan price change | Affects 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. |
enrollMember | Sub active with no payment; skipPayment=true does NOT touch payment provider. Useful for comp memberships. |
freezeSubscription | Bookings refuse credits until resumed. Charge cron skips paused subs (selector requires status IN ('active','past_due')). |
| Cancellation request created | 2 emails + 1 high-urgency task. |
| Cancellation request approved with refund | Sub canceled, refund flow runs, member email, observability payment.cancellation_request_approved. Refund task created if provider is manual. |
Edge cases
| Case | Behaviour |
|---|---|
| Member purchases free plan they already have active | 409 ConflictException (plans.service.ts:265). |
| Member retries an abandoned checkout | Pending sub reused; fresh payment page issued; no duplicate subscriptions row. |
| Webhook arrives for already-active sub | Activate transaction short-circuits; period not re-advanced if status was already active and the transition pending → active isn’t taken. |
| Owner cancels a paused sub | Allowed; transitions paused → cancelled. |
| Owner cancels a sub already cancelled | 400 Subscription is already cancelled. |
| Member cancels twice | Second request — guard at cancellation-requests.service.ts:84 rejects with 400 request is already pending. |
| Adjust credits to negative | Clamped to 0 (subscriptions.service.ts:815). |
| Adjust credits on unlimited plan | 400 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_id | Selector excludes it (AND payment_method_id IS NOT NULL — recurring-charge.service.ts:71). The sub will quietly never renew. |