Subscriptions & Plans — Code Map
API
apps/api/src/plans/
plans.module.tsplans.controller.ts—@Controller('organizations/:orgId/plans').RequiresFeature('membership_plans')on mutating routes.plans.service.ts—create | findAll | findById | update | remove | purchase | resumeCheckout. InternalrequireFeaturehelper for service-layer tier checks.resumeCheckoutis a thin front door ontopurchase(resumeSubscriptionIdpins the reuse branch to one row) so the checkout mechanics exist once.plans.service.unit.spec.ts— unit tests.dto/create-plan.dto.tsupdate-plan.dto.tspurchase-plan.dto.ts—{ successUrl, cancelUrl, replacesSubscriptionId }.
apps/api/src/subscriptions/
subscriptions.module.tssubscription-lifecycle.service.ts—SubscriptionLifecycleService(hourlytick,run,runForOrg) and the purelifecycleTransition: natural ends, cash-lapse / pay-later debt entry, ₪0 rolls, day-N non-payment cancellation, last-day notice.subscription-display-status.ts—non_paymentreadscancelled;expired/gift_ended/term_endedreadexpired.subscriptions.controller.ts— both member-scoped (/subscriptions/my/*) and admin-scoped (/subscriptions/:id/*) routes.subscriptions.service.ts— lifecycle (endSubscription,findBookableForMembership). Cancellation entry points arememberRequestCancellation(timestamped obligation only),issueMemberCancellation(staff-owned request → legal notice), andcancelSubscription(business termination).setDiscountowns 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, andcreateSettlementReminderTask. Routing isRefundCapabilityService.resolveExecution. See Settlement.
- Settlement at the effective date lives here too:
cancellation-math.ts—computeCancellationRefund, the proportional-refund math over an explicit effective date, routed throughplan-change-math’scomputeDueNow.retention-case.service.ts—RetentionCaseService:openForRequestcreates the owner obligation andopenForNoticepromotes the same task at staff issuance.withdrawNoticeByMemberand privateclearCancellationclose 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 atorganizations/: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) andresolveMemberActions(authoritative array). Live pending requests and issued notices exposeresume_cancelfor every provider; external billing suppresses money actions, not requesting or withdrawing cancellation. Managedpast_due/debtretain their primary recovery action alongside staying.scheduledreturnschange_planalongsidewithdraw_scheduledwhen the plan carries aplanGroupId. Neverrenewfor aclass_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.resolveDiscountWindowperforms 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, plusPRESALE_WITHDRAWN_REASON,isPresaleWithdrawalandresolveCheckoutReleasedBy(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, theexternal | ending | recurring | consumable | prepaid | paused | stoppedderivation shared by every client-facing billing label (consumable= a live punch card or drop-in). Seememberships/behavior.md.holding-state.ts—resolveHoldingFields:kind,validUntil,consumableStatus,consumableEndedAtfor every serializer with the plan in scope (subscriptions.controller.ts,plans.controller.ts, the presale swap response).holding-kind.int.spec.tscovers/subscriptions/myagainst Postgres, including a cash pack staff cancelled at the desk (cancelCashPlan). See Holding kind.member-subscriptions-scope.int.spec.ts— the staffmembers/:membershipId/subscriptionsread is a 404 for a membership of another org.libs/shared/src/lib/holdings.ts—holdingKind(kind, falling back toplan.typefor an older API; every client kind check) andpartitionHoldings, 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 closesrebought), the three refusals, theuse_withdrawredirect, 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 duecancel_at_period_endrows tocancelledviaSubscriptionsService.sweepDueCancellations;@ScheduledJob('CancellationCronService.handleRetentionEscalation', '15 * * * *')runsRetentionCaseService.runEscalationSweephourly. 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— seebehavior.md’s Presale swap). Depends onPlansServiceviaforwardRef(member checkout delegates topurchase) andMembershipWelcomeService(@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 (includingPlansService.purchase’sreplacesSubscriptionIdpath) without a circular constructor dependency.scheduled-status subs get their own arm: eligible only when the target plan sharesplanGroupId.plan-change.service.driver.ts,plan-change.service.unit.spec.ts,plan-change-math.unit.spec.ts— tests.dto/enroll-member.dto.tschange-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 aspurchase-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=endingcancellations queue (extra Notice/Ends on/Case/Owner columns) andmembers-ribbon.tsx’s “Ending soon” tile. Seedashboard/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; showscurrentPeriodEnd, the same date the staff badge inapps/web/src/components/overview/members/member-memberships-tab.tsxshows.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) insideMemberHoldings.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; testsmember-holdings.driver.tsx+.int.spec.tsx. Used by profile, profile payments andcomponents/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 fromBookingsModuleto 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 ofBookingsServiceso the sweep engine can reuse them without a DI cycle back through it. The rules themselves areevaluateBookinginlibs/shared/src/lib/booking-evaluator.ts;classifyForNewSubasks it per candidate.- C3 (booking-horizon guard):
book()inapps/api/src/bookings/bookings.service.tsjudges it throughevaluateBooking(plan_ends_before_session), with the horizon itself inbooking-horizon.ts. - C4 call sites:
apps/api/src/subscriptions/subscriptions.service.ts(sweepOnEnd: immediate cancel, period-end cron, andendSubscriptionfor lifecycle ends incl. non-payment with thepayment_issuevariant).
Shared
libs/shared/src/lib/schemas/plan.schema.ts— Zod schemas forPlanResponse+ the plan-change request/response types (ChangePlanRequest,MemberChangePlanRequest,MemberChangePlanResponse— now a union includingMemberChangePlanPresaleSwapResponse,ChangePlanPreviewResponse— carries optionalopeningDayChargeInCents/openingDayAtfor the presale-swap preview,PreviewBooking, direction/timing/billing enums).memberSubscriptionActionValuesgainedchange_plan.libs/shared/src/lib/schemas/membership.schema.ts—SubscriptionResponse(now carriescancellationReasonCode,retention),SubscriptionWithPlan,endingSummarySchema/EndingSummary(the?paymentStatus=endingmember-row columns),listMembersQuerySchema(paymentStatusgainedending).libs/shared/src/lib/schemas/cancellation.schema.ts— request input, staff cancellation input, reason-code/retention/contact value lists,RetentionActivity, and response schemas.RetentionActivityis the CRM/reporting contract for audit-backed action kind, actor identity, timestamp, channel, note, attempt, and outcome.memberCancellationRequestSchemarequires a reason code and conditionally requires explanation forother; the separate staff notice schema keeps reasons optional andcancelSubscriptionSchemarejects immediate member cancellation.libs/shared/src/lib/analytics/cancellation-events.ts—CancellationEvents, the typedcancellation.*/plan_change.presale_swapserver event names plus the eight client event names, shared by API/web/native. Seeevent-tracking/README.md.libs/shared/src/lib/constants/payment-error-codes.ts—PaymentErrorCodesregistry (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’sPRESALE_SWAP_REQUIRES_SAME_GROUP,PLAN_CHANGE_CHARGE_IN_FLIGHT,CANCELLATION_ALREADY_SCHEDULED,REFUND_PROVIDER_MISMATCH(seepayments/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’sscheduled_plan_id/plan_change_scheduled_at/changed_from_subscription_id, and this feature’scancellation_reason_code+ nineretention_*columns). Nocancellation_requeststable.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, pluscancellationReasonCode,retentionOutcome,retentionCaseKind(this feature).libs/db/src/lib/schema/organizations.ts—organizations.retentionSlaHours(this feature).libs/db/src/lib/schema/memberships.ts—memberships.paymentStatusis 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, thecancel_at_period_endindex re-key);0126_smart_vanisher.sqladds therequestcase 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. Seenotifications/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 viadictionaries[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— mutatessubscriptionson payment events; hosts the plan-change checkout activation hook (§4.3).apps/api/src/payments/services/recurring-charge.service.ts— mutatessubscriptionsandmemberships.paymentStatusdaily; hosts the scheduled-change boundary swap (§4.4, PD-B5a).apps/api/src/payments/services/manual-charge.service.ts—CHARGE_VERIFIED_PROVIDERS, imported byplan-change.service.tsfor 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;openManualRefundTasksendsrefundProcessing.apps/api/src/payments/services/refund-task-sweep.service.ts— the daily overdue-refund-task sweep. Seepayments/behavior.md.apps/api/src/payments/services/recurring-charge.service.ts— also hostshandlePresaleChargeFailure’s retention-case task/email on the first opening-day decline, andpayment.presale_promotion_run’s per-org run summary.apps/api/src/automations/automation-scheduler.service.ts—sweepEnded’splan_change/ replacement-sub exclusions (§4.8, PD-B9).apps/api/src/bookings/— credit debit/reversal go throughbooking-enforcement.util.ts→entitlements/entitlement-ledger.ts; manual refunds callsubscriptionsService.refundCredit;bookings.service.tshosts 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(openUnopenedLedgersfor seeds andTestingService),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— usesplan.type='course'rows, bypassesplans.purchase.
Entry quotas (TKN-446)
| File | Purpose |
|---|---|
libs/shared/src/lib/schemas/plan-entitlement-bucket.schema.ts | planEntitlementBucketSchema, replacePlanEntitlementBucketsSchema (whole-list PUT), entitlementBucketBalanceSchema. |
apps/api/src/booking-policy/plan-entitlement-buckets.service.ts | List / 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.ts | bucketsForSubscriptions (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.ts | bucketBalancesFor(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.ts | getBookingEligibility 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.tsx | usePlanBuckets (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.ts | TKN-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.ts | Includes a race for the last slot of a quota: one confirmed, the rest bucket_exhausted, ledger intact. |