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

Subscriptions & Plans — Code Map

API

apps/api/src/plans/

  • plans.module.ts
  • plans.controller.ts — @Controller('organizations/:orgId/plans'). RequiresFeature('membership_plans') on mutating routes.
  • plans.service.ts — create | findAll | findById | update | remove | purchase | resumeCheckout. Internal requireFeature helper for service-layer tier checks. resumeCheckout is a thin front door onto purchase (resumeSubscriptionId pins the reuse branch to one row) so the checkout mechanics exist once.
  • plans.service.unit.spec.ts — unit tests.
  • dto/
    • create-plan.dto.ts
    • update-plan.dto.ts
    • purchase-plan.dto.ts — { successUrl, cancelUrl, replacesSubscriptionId }.

apps/api/src/subscriptions/

  • subscriptions.module.ts
  • subscription-lifecycle.service.ts — SubscriptionLifecycleService (hourly tick, run, runForOrg) and the pure lifecycleTransition: natural ends, cash-lapse / pay-later debt entry, ₪0 rolls, day-N non-payment cancellation, last-day notice.
  • subscription-display-status.ts — non_payment reads cancelled; expired / gift_ended / term_ended read expired.
  • subscriptions.controller.ts — both member-scoped (/subscriptions/my/*) and admin-scoped (/subscriptions/:id/*) routes.
  • subscriptions.service.ts — lifecycle (endSubscription, findBookableForMembership). Cancellation entry points are memberRequestCancellation (timestamped obligation only), issueMemberCancellation (staff-owned request → legal notice), and cancelSubscription (business termination). setDiscount owns permanent/manual prices and managed-provider bounded percentage offers, including their next-charge date window. Also includes the due-cancellation sweep/repair pass, checkout/withdrawal actions, and provider standing-order cancellation.
    • Settlement at the effective date lives here too: settleCancellationAtEffectiveDate, findPeriodCharge, wasSettled, and createSettlementReminderTask. Routing is RefundCapabilityService.resolveExecution. See Settlement.
  • cancellation-math.ts — computeCancellationRefund, the proportional-refund math over an explicit effective date, routed through plan-change-math’s computeDueNow.
  • retention-case.service.ts — RetentionCaseService: openForRequest creates the owner obligation and openForNotice promotes the same task at staff issuance. withdrawNoticeByMember and private clearCancellation close pending requests or live notices through the same task/outcome/unsigned-paperwork lifecycle, without payment-provider restrictions. Also owns presale cases, staff actions, escalation, and digest.
  • retention-case.controller.ts — RetentionCaseController, mounted at organizations/:orgId/subscriptions/:id/…: retention/acknowledge, retention/contact, retention/activity, retention/outcome, withdraw-notice. The activity read returns the current case’s audit-backed actor/timestamp/action/result ledger for CRM and reporting.
  • member-action.ts — resolveMemberAction (legacy singular) and resolveMemberActions (authoritative array). Live pending requests and issued notices expose resume_cancel for every provider; external billing suppresses money actions, not requesting or withdrawing cancellation. Managed past_due/debt retain their primary recovery action alongside staying. scheduled returns change_plan alongside withdraw_scheduled when the plan carries a planGroupId. Never renew for a class_pack / drop_in.
  • libs/shared/src/lib/intro-pricing.ts — single money resolver for permanent negotiated prices, bounded percentage windows, intro pricing, and list-price restoration. resolveDiscountWindow performs UTC calendar-month and month-end calculations.
  • libs/db/drizzle/0128_bounded_subscription_discounts.sql — adds percentage, duration, inclusive-start and exclusive-end subscription fields.
  • apps/web/src/components/overview/members/subscription-discount-dialog.tsx — staff permanent/temporary pricing modes, managed-provider eligibility, exact price/date preview, and retention-case save chaining.
  • subscription-display-status.ts — the terminal-status split, plus PRESALE_WITHDRAWN_REASON, isPresaleWithdrawal and resolveCheckoutReleasedBy (which of the three writers released an abandoned checkout). Deliberately dependency-free (no Nest service imports) so label logic stays unit-testable.
  • billing-state.ts — resolveBillingState/resolveNextChargeAt, the external | ending | recurring | consumable | prepaid | paused | stopped derivation shared by every client-facing billing label (consumable = a live punch card or drop-in). See memberships/behavior.md.
  • holding-state.ts — resolveHoldingFields: kind, validUntil, consumableStatus, consumableEndedAt for every serializer with the plan in scope (subscriptions.controller.ts, plans.controller.ts, the presale swap response). holding-kind.int.spec.ts covers /subscriptions/my against Postgres, including a cash pack staff cancelled at the desk (cancelCashPlan). See Holding kind.
  • member-subscriptions-scope.int.spec.ts — the staff members/:membershipId/subscriptions read is a 404 for a membership of another org.
  • libs/shared/src/lib/holdings.ts — holdingKind (kind, falling back to plan.type for an older API; every client kind check) and partitionHoldings, the one primary / other memberships / pack rows split web and mobile render.
  • presale-withdraw.int.spec.ts — withdraw happy path, seat release, re-purchase (now also asserts the retention case closes rebought), the three refusals, the use_withdraw redirect, and the cron repair pass.
  • Ledger writers outside the booking path, against real Postgres: apps/api/src/payments/services/billing-resilience-ledger.int.spec.ts (settlement: presale grant once under replayed settlement, paid renewal refill, debt entry on credit), webhook-ledger.int.spec.ts (activation grant; a replayed payment webhook refills nothing spent), cash-plan-payment.int.spec.ts (a pack’s credit note prorates by the ledger, also when the mirror reads -1), billing-resilience-readiness.int.spec.ts (schedule repair closes the ledger), apps/api/test/e2e/entitlement-ledger.e2e.spec.ts (immediate plan change grants and carries the kept booking).
  • checkout-decision.int.spec.ts — regret rollback + member-list hiding, the rows that must stay visible (swept, staff-dismissed), transaction metadata, staff void-pending, checkout-return telemetry.
  • cancellation-cron.service.ts — @ScheduledJob('CancellationCronService.handlePeriodEndCancellations', '30 2 * * *') flips due cancel_at_period_end rows to cancelled via SubscriptionsService.sweepDueCancellations; @ScheduledJob('CancellationCronService.handleRetentionEscalation', '15 * * * *') runs RetentionCaseService.runEscalationSweep hourly. Both run on the BullMQ scheduler queue (ADR-0020), not on @nestjs/schedule.
  • cancellation-flow.int.spec.ts — request → staff issuance on the same task, pending-request stay and repeat/ownership/terminal refusals, provider-neutral issued-notice withdrawal, plus the notice/retention/sweep lifecycle.
  • staff-cancellation.unit.spec.ts — request non-effects, duplicate/type eligibility, original request preservation, issuance, preview, refund, and legal date calculations.
  • plan-change.controller.ts (FIT-254 §4) — preview, org change, cancel-scheduled, member self-serve change. One controller, no class-level route prefix (member vs. staff routes have different shapes).
  • plan-change.service.ts — PlanChangeService: previewChangePlan, orgChangePlan, memberChangePlan, cancelScheduledChange, the three immediate-variant private methods (applyImmediateCharge, applyImmediateComp, scheduleChange), and the presale-swap trio (previewPresaleSwap, applyPresaleSwap, buildPresaleSwapResponse — see behavior.md’s Presale swap). Depends on PlansService via forwardRef (member checkout delegates to purchase) and MembershipWelcomeService (@Optional, re-sends the presale confirmation on a swap).
  • plan-change-math.ts — pure proration math, zero framework dependency: computeDueNow, classifyDirection, PLAN_CHANGE_INTERVAL_DAYS.
  • plan-change-eligibility.ts — enforcePlanChangeEligibility, the standalone §4.1 eligibility matrix shared by every plan-change entry point (including PlansService.purchase’s replacesSubscriptionId path) without a circular constructor dependency. scheduled-status subs get their own arm: eligible only when the target plan shares planGroupId.
  • plan-change.service.driver.ts, plan-change.service.unit.spec.ts, plan-change-math.unit.spec.ts — tests.
  • dto/
    • enroll-member.dto.ts
    • change-plan.dto.ts — org-initiated { newPlanId, timing, billing? }.
    • member-change-plan.dto.ts — member self-serve { newPlanId, successUrl?, cancelUrl? }.
    • resume-checkout.dto.ts — { successUrl?, cancelUrl?, failedUrl? }; same shape and same loose validation as purchase-plan.dto.ts.
    • cancel-pending.dto.ts — optional { intent?, source? }; an empty body is today’s behavior.
    • checkout-return.dto.ts — { outcome, client } for the telemetry endpoint.

Web (apps/web/src/)

  • app/[lang]/(protected)/dashboard/plans/ — plan CRUD UI.
  • app/[lang]/(protected)/dashboard/payments/ — owner-side payments console.
  • app/[lang]/(protected)/dashboard/members/members-content.tsx — member roster, including the ?paymentStatus=ending cancellations queue (extra Notice/Ends on/Case/Owner columns) and members-ribbon.tsx’s “Ending soon” tile. See dashboard/behavior.md.
  • components/subscriptions/staff-cancel-dialog.tsx — staff issuance/business-termination dialog. A pending mobile request is locked to the member-notice path and original timestamp; direct offline notices expose channel/date inputs. Issuance hands off to the generated form signing link.
  • components/overview/members/retention-case-panel.tsx — request/notice retention panel: request deadline communicates that no cancellation exists yet and exposes the staff-only issuance action; notice cases retain acknowledge/contact/save/withdraw controls. Its chronological activity ledger shows every retention action with actor, timestamp, channel, note, and saved/lost result.
  • components/member/plan-card.tsx — member-facing subscription summary.
  • components/member/change-plan-sheet.tsx (+ .driver.tsx, .int.spec.tsx) — member self-serve “Change plan” sheet (FIT-254 §4.5): preview → upgrade redirect or downgrade confirm.
  • components/member/scheduled-plan-change-banner.tsx (+ .driver.tsx, .int.spec.tsx) — member-facing scheduled-change banner; shows currentPeriodEnd, the same date the staff badge in apps/web/src/components/overview/members/member-memberships-tab.tsx shows.
  • components/member/switch-plan-picker-dialog.tsx — shop “Switch to this plan” entry point when the member holds several subscription-type subs.
  • components/overview/members/member-memberships-tab.tsx — staff member-detail Memberships tab; wires request tasks into locked staff cancellation issuance.
  • components/overview/members/payment-methods-card.tsx — staff-facing card-on-file card (FIT-254 §3.1).
  • components/members/manual-charge-dialog.tsx, components/members/collect-debt-dialog.tsx — staff desk-charge and debt-collection dialogs (FIT-254 §3.3/§3.4).
  • app/[lang]/(protected)/(member)/profile/payments/page.tsx — member profile payments page; the primary membership card (change-plan entry point, scheduled-change banner, early renew) inside MemberHoldings.
  • components/member/holdings/ — member-holdings.tsx (MemberHoldings: primary card slot, other-membership rows, pack rows, packs-run-out line, browse link), holding-rows.tsx (HoldingPackRow, HoldingOtherPlanRow), holding-actions.tsx (useHoldingActions: checkout / presale / stay actions and their dialogs, one per page), buy-again-href.ts; tests member-holdings.driver.tsx + .int.spec.tsx. Used by profile, profile payments and components/member/family/child-plan-summary.tsx.

Booking-entitlement sweep (FIT-254 §4.6/§5)

  • apps/api/src/bookings/booking-entitlement-sweep.service.ts — BookingEntitlementSweepService: plan/planForTargetPlan (zero-write preview), apply/applyInPlace (writes).
  • apps/api/src/bookings/booking-entitlement-sweep.module.ts — module wiring (kept separate from BookingsModule to avoid the DI cycle documented in the service’s doc-comment).
  • apps/api/src/bookings/booking-entitlement-sweep.service.driver.ts, .unit.spec.ts — tests.
  • apps/api/src/bookings/booking-enforcement.util.ts — shared low-level primitives (sessionsOverlap, loadBookingUsage, bookingBlockedException, deductCreditAtomic, tryDeductCreditAtomic, waitlistHoldCovered, fillFreeSeats, withSerializableRetry; waitlist rules in scheduling-bookings › Waitlist) extracted out of BookingsService so the sweep engine can reuse them without a DI cycle back through it. The rules themselves are evaluateBooking in libs/shared/src/lib/booking-evaluator.ts; classifyForNewSub asks it per candidate.
  • C3 (booking-horizon guard): book() in apps/api/src/bookings/bookings.service.ts judges it through evaluateBooking (plan_ends_before_session), with the horizon itself in booking-horizon.ts.
  • C4 call sites: apps/api/src/subscriptions/subscriptions.service.ts (sweepOnEnd: immediate cancel, period-end cron, and endSubscription for lifecycle ends incl. non-payment with the payment_issue variant).

Shared

  • libs/shared/src/lib/schemas/plan.schema.ts — Zod schemas for PlanResponse + the plan-change request/response types (ChangePlanRequest, MemberChangePlanRequest, MemberChangePlanResponse — now a union including MemberChangePlanPresaleSwapResponse, ChangePlanPreviewResponse — carries optional openingDayChargeInCents/openingDayAt for the presale-swap preview, PreviewBooking, direction/timing/billing enums). memberSubscriptionActionValues gained change_plan.
  • libs/shared/src/lib/schemas/membership.schema.ts — SubscriptionResponse (now carries cancellationReasonCode, retention), SubscriptionWithPlan, endingSummarySchema/EndingSummary (the ?paymentStatus=ending member-row columns), listMembersQuerySchema (paymentStatus gained ending).
  • libs/shared/src/lib/schemas/cancellation.schema.ts — request input, staff cancellation input, reason-code/retention/contact value lists, RetentionActivity, and response schemas. RetentionActivity is the CRM/reporting contract for audit-backed action kind, actor identity, timestamp, channel, note, attempt, and outcome. memberCancellationRequestSchema requires a reason code and conditionally requires explanation for other; the separate staff notice schema keeps reasons optional and cancelSubscriptionSchema rejects immediate member cancellation.
  • libs/shared/src/lib/analytics/cancellation-events.ts — CancellationEvents, the typed cancellation.*/plan_change.presale_swap server event names plus the eight client event names, shared by API/web/native. See event-tracking/README.md.
  • libs/shared/src/lib/constants/payment-error-codes.ts — PaymentErrorCodes registry (FIT-254): NO_ACTIVE_PAYMENT_METHOD, MEMBERSHIP_NOT_ACTIVE, PROVIDER_CHARGE_UNVERIFIED, PAYMENT_METHOD_IN_USE, OUTSTANDING_BALANCE, PENDING_ACTION_CONFLICT, RESUME_FIRST, BOOKING_BEYOND_SUBSCRIPTION_END, RENEWAL_CHARGE_FAILED, PLAN_CHANGE_CHARGE_FAILED, plus this feature’s PRESALE_SWAP_REQUIRES_SAME_GROUP, PLAN_CHANGE_CHARGE_IN_FLIGHT, CANCELLATION_ALREADY_SCHEDULED, REFUND_PROVIDER_MISMATCH (see payments/behavior.md).
  • Cancellation retention, settlement, provider standing-order cancellation and form prompting are always-on. Unrelated payment and plan-change controls remain separate.

DB

  • libs/db/src/lib/schema/payments.ts — plans, subscriptions (+ FIT-254’s scheduled_plan_id/plan_change_scheduled_at/changed_from_subscription_id, and this feature’s cancellation_reason_code + nine retention_* columns). No cancellation_requests table.
  • libs/db/src/lib/schema/audit-events.ts — append-only source for the per-case retention activity ledger. Existing organization/target indexing and structured metadata support CRM history and reporting without a second retention-history table or migration.
  • libs/db/src/lib/schema/enums.ts — planType, planInterval, subscriptionStatus, membershipPaymentStatus, plus cancellationReasonCode, retentionOutcome, retentionCaseKind (this feature).
  • libs/db/src/lib/schema/organizations.ts — organizations.retentionSlaHours (this feature).
  • libs/db/src/lib/schema/memberships.ts — memberships.paymentStatus is mutated by the renewal cron + webhook processor.
  • libs/db/drizzle/0096_cynical_blue_marvel.sql — migration adding the three plan-change columns + their FKs.
  • libs/db/drizzle/0125_needy_zuras.sql — this feature’s migration (the three enums, the retention-case columns, retention_sla_hours, the cancel_at_period_end index re-key); 0126_smart_vanisher.sql adds the request case kind.

Notifications

  • apps/api/src/notifications/cancellation-notifications.service.ts — cancellationScheduled, membershipEnded, refundIssuedForTransaction, refundCompleted, and this feature’s five: presaleWithdrawnForStaff, presaleWithdrawnForMember, presaleChargeFailedForStaff, noticeWithdrawnByStaff, refundProcessing. resolveCaseOwnerRecipient(orgId, retentionOwnerId) resolves the case owner (falling back to the org’s active owner) for the two sends that stay immediate. See notifications/README.md.
  • apps/api/src/notifications/templates/retention-staff-emails.ts — the two still-immediate owner/admin templates (presaleWithdrawnForStaffEmail, presaleChargeFailedForStaffEmail) above.
  • apps/api/src/notifications/templates/payment-emails.ts — paymentFailedEmail, debtOpenedEmail, debtLastDayEmail, debtCancelledEmail, cardUnusableEmail, receipt.
  • apps/api/src/notifications/sweep-notifications.service.ts (FIT-254) — SweepNotificationsService: sendPlanChangeSwept, sendSubscriptionEndedSwept, sendPlanChangeScheduled, sendPlanChangeScheduleCancelled. Resolves recipient locale (device → org → en) and org branding.
  • apps/api/src/notifications/templates/entitlement-sweep.ts — planChangeSweepEmail, subscriptionEndedSweepEmail. Localized en/he/ru via dictionaries[locale].entitlementSweepEmail.copy.
  • apps/api/src/notifications/templates/plan-change-scheduled.ts — planChangeScheduledEmail, planChangeScheduleCancelledEmail. Localized en/he/ru.

Cross-references

  • apps/api/src/payments/services/webhook-processing.service.ts — mutates subscriptions on payment events; hosts the plan-change checkout activation hook (§4.3).
  • apps/api/src/payments/services/recurring-charge.service.ts — mutates subscriptions and memberships.paymentStatus daily; hosts the scheduled-change boundary swap (§4.4, PD-B5a).
  • apps/api/src/payments/services/manual-charge.service.ts — CHARGE_VERIFIED_PROVIDERS, imported by plan-change.service.ts for the org saved-card charge variant.
  • apps/api/src/payments/services/payment.service.ts — refund() now resolves the adapter/credentials for the TRANSACTION’s own provider, not blindly the org’s active one; openManualRefundTask sends refundProcessing. apps/api/src/payments/services/refund-task-sweep.service.ts — the daily overdue-refund-task sweep. See payments/behavior.md.
  • apps/api/src/payments/services/recurring-charge.service.ts — also hosts handlePresaleChargeFailure’s retention-case task/email on the first opening-day decline, and payment.presale_promotion_run’s per-org run summary.
  • apps/api/src/automations/automation-scheduler.service.ts — sweepEnded’s plan_change / replacement-sub exclusions (§4.8, PD-B9).
  • apps/api/src/bookings/ — credit debit/reversal go through booking-enforcement.util.ts → entitlements/entitlement-ledger.ts; manual refunds call subscriptionsService.refundCredit; bookings.service.ts hosts C3 inline.
  • apps/api/src/entitlements/ — entitlement-ledger.ts (tx-scoped ledger writes: recordLedgerEntry, debitBookingCredit, reverseBookingCredit, recordCreditRebase, closeLedger, ledgerBalance, creditBalance, creditBalanceSql, keys, reason codes), open-ledgers.ts (openUnopenedLedgers for seeds and TestingService), entitlement-balance.service.ts (grouped ledger balance reads: withBalances), entitlement-drift.service.ts (daily drift job), entitlements.module.ts (global). Manual changes: SubscriptionsService.adjustCredits / refundCredit → applyManualCreditChange. Backfill: scripts/backfill-entitlement-ledger.ts. Schema: libs/db/src/lib/schema/entitlements.ts. Shared zod: libs/shared/src/lib/schemas/entitlement.schema.ts. Staff UI: apps/web/src/components/overview/members/credit-adjust-popover.tsx.
  • apps/api/src/courses/course-checkout.service.ts — uses plan.type='course' rows, bypasses plans.purchase.

Entry quotas (TKN-446)

FilePurpose
libs/shared/src/lib/schemas/plan-entitlement-bucket.schema.tsplanEntitlementBucketSchema, replacePlanEntitlementBucketsSchema (whole-list PUT), entitlementBucketBalanceSchema.
apps/api/src/booking-policy/plan-entitlement-buckets.service.tsList / replace a plan’s quotas (flag-gated write, audit plan.buckets_updated). Routes in booking-policy.controller.ts (PlanEntitlementBucketsController).
apps/api/src/entitlements/entitlement-buckets.tsbucketsForSubscriptions (each subscription’s plan’s live quotas, one read), bucketWindow, countMatchingBookings (one grouped read of bookings + sessions + class types, matched per quota scope and window), loadBucketFacts / loadBucketUsage, bucketBalances.
apps/api/src/entitlements/entitlement-balance.service.tsbucketBalancesFor(orgId, subs): flag-gated quota balance lines for the subscription reads (subscription.bucketBalances in subscriptions.controller.ts).
apps/api/src/class-sessions/class-sessions.service.tsgetBookingEligibility matches and counts every (plan, session) pair’s quotas in one read; bookingPreview (GET …/sessions/:id/booking-preview) builds the staff trace from the same loaders.
apps/web/src/components/overview/plans/plan-buckets-editor.tsxusePlanBuckets (the plan’s day/week/month limits as “All classes” rows plus its quotas; limits for the plan body, save for the quota PUT, duplicates, unnamed) and PlanBucketsEditor, the one list in the plan sheet. plan-buckets-card.tsx shows the same list on the plan page.
apps/api/src/booking-policy/booking-policy-victor.int.spec.tsTKN-442 fixtures on real Postgres: 18+9 with a yoga quota of 0, pooled halls, every matching quota limits, per-hall windows, snapshot cancel terms, same-layer conflicts, a quota added mid-cycle applies to the next booking, a ledger with no quota attribution, constant-query week read.
apps/api/src/bookings/booking-concurrency.int.spec.tsIncludes a race for the last slot of a quota: one confirmed, the rest bucket_exhausted, ledger intact.