ClientSubscription row is one plan (product) sold to one trainee for a span of days. A trainee can hold several: one running, more queued behind it, and a history of expired and canceled rows. This module is the source of truth for the plan fields that are mirrored onto the Client row.
Source: backend/apps/core-api/src/modules/client-subscriptions/.
Mounting and auth
The module has no entry of its own inmodules/index.ts. Its router is mounted inside the clients router, in clients.routes.ts:
clientSubscriptionsRouter is created with Router({ mergeParams: true }) so it can read :clientId. Because the clients router is mounted on both lanes, the routes exist under two prefixes:
The headings below use the web prefix.
Who can call it. There is no
requireRole guard. The routes inherit the clients router’s middleware: withCoachAccess and the /:id prefix guard requireAssignedClient. A coach restricted to their own trainees gets NOT_FOUND for a trainee they are not assigned to. A dismissed team member gets FORBIDDEN. See Clients for the scoping rules and API overview for the lanes and envelope.
The coach
permissions.subscriptions setting (assign or manage) is stored and resolved by middleware/coach-access.ts, but nothing in this module or in the clients router checks it. Enforcement on the server was not found in the code read for this page.POST /v1/web/clients/:id/freeze and POST /v1/web/clients/:id/reactivate. They are documented on the Clients page.
Data model
Fields ofClientSubscription (packages/db/prisma/schema.prisma):
Statuses
“Live” in this module means
SCHEDULED, ACTIVE, FROZEN and AWAITING_START. The list endpoint returns live rows only.
Start mode
start-mode.ts reads subscriptionStartMode from the studio’s settings JSON. Values: ASSIGNMENT (default, also used for a missing or unknown value), INTAKE_FORM, FIRST_PLAN, FIRST_WORKOUT.
A new subscription waits (AWAITING_START) only when all of these hold (waitingTriggerFor):
- The studio’s start mode is not
ASSIGNMENT. - The caller sent no start date.
- The trainee has no live subscription.
- The trainee has not already done the event.
INTAKE_FORMchecks for aFormResponseto a form of typeINTAKE.FIRST_PLANchecks for a program that is notDRAFTand is not aPAUSEDprogram withreleasePending.FIRST_WORKOUTchecks for a completedWorkoutLog.
Coverage
coverage.ts exports LIVE_SUBSCRIPTION_STATUSES (SCHEDULED, ACTIVE, FROZEN, the live rows that carry dates) and coverageEndsOn(mirrorEndsOn, subs). Coverage is the latest endsOn across the client’s mirrored endsOn and those rows. It is what the clients API returns as coverageEndsOn, so a trainee with a renewal queued does not read as expiring on the current plan’s last day.
Mirror on the client
After every change,syncMirror rewrites these Client columns from the primary subscription (the ACTIVE or FROZEN row, else the SCHEDULED row, else the waiting row): productId, planName, priceAgorot, startedOn, endsOn, planFrozenOn, planRemainingDays and status. appAccessWhileFrozen is reset to false whenever the primary row is not frozen.
Client.status is derived by deriveClientStatus:
CHURN_RISKandARCHIVEDare left as they are.- A
FROZENsubscription givesPAUSED. - An
ACTIVEsubscription, or any scheduled or waiting one, givesACTIVE. - Nothing live gives
CHURNED.
endsOn is set to the last day the terminal rows covered. A cancellation can only shorten coverage: it is capped at the row’s own endsOn, and a row canceled before it started covers nothing.
Day comparisons
Overlap, “has started” and forward-span checks compare civil days in the studio’s timezone (dayKey), not instants. Two subscriptions may share a single boundary day, which is how a back-to-back renewal is stored: the new plan starts on the day the old one ends.
Endpoints
GET /v1/web/clients/:clientId/subscriptions
Lists the trainee’s live subscriptions (SCHEDULED, ACTIVE, FROZEN, AWAITING_START), ordered by startedOn ascending. Expired and canceled rows are not returned here. The trainee board (GET /v1/web/clients/:id/board) returns every status.
Auth: web lane or bearer lane, any role, coach scope applied by the clients router.
string
required
Client id.
ClientSubscription rows.
NOT_FOUND (“client not found”). VALIDATION for a bad path parameter.
POST /v1/web/clients/:clientId/subscriptions
Assigns a plan to the trainee as a new subscription. Responds with status 201.
Auth: web lane or bearer lane, any role, coach scope applied by the clients router.
string
required
Client id.
string
required
The plan to assign. Must belong to the studio.
date | null
Start date. Coerced to a date.
null reads as absent. When absent, the studio’s start mode decides.number
Non-negative integer. Overrides the product price for this subscription.
number
Positive integer. Overrides the product duration. Required when the product has no duration of its own.
string
DAYS or MONTHS. Overrides the product’s unit.addSubscription), inside one transaction:
- Runs
reconcilefor the trainee, so due rows are expired or activated first. - Resolves the duration: body first, then product.
- Refuses if the trainee already has an
AWAITING_STARTrow. - Decides whether the new row waits (see “Start mode”).
- Waiting: creates the row with status
AWAITING_START, no dates, and the trigger instartTrigger. - Dated: the start is
startedOn, or the latest end date among the live rows, or now when nothing is live. The end is the start plus the duration. The row is refused if it overlaps a live dated row. Status isACTIVEwhen the start day has arrived and nothing isACTIVEorFROZEN, otherwiseSCHEDULED.
- Waiting: creates the row with status
- Runs
syncMirror.
- Automation hook.
subscriptionAssignedHookruns with{ studioId, clientId, subscriptionId, productId, planName }. It starts theSUBSCRIPTION_ASSIGNEDtask automation runs and the new-client-in-plan runs through the flow-run queue (BullMQ). Errors in the hook are caught and do not fail the request. - Activity. A
ClientActivityrow with typeSUBSCRIPTION_PLAN, actionASSIGNED, entity typeSUBSCRIPTION, the product id asentityIdand the product name asentityName. - Push. A
SUBSCRIPTION_RENEWEDnotification to the trainee with the varsplan(fromtraineePlanName(product)) andstudio.
NOT_FOUND: “client not found” or “plan not found”.BAD_REQUEST: the plan sets no duration and none was sent. The start plus the duration does not land after the start day.CONFLICT: the trainee already has a subscription waiting to start, or the requested dates overlap a live subscription.details.conflictslists each blocking row.VALIDATION: body fails the schema.
details.conflicts entries look like this:
startedOn, endsOn, blocksFrom and blocksUntil are null. Some imported rows have startedOn after endsOn. For those, blocksFrom and blocksUntil show the two dates in forward order.
POST /v1/web/clients/:clientId/subscriptions/:subscriptionId/start
Starts a subscription that is waiting (AWAITING_START), today or on a chosen day in the past or future.
Auth: web lane or bearer lane, any role, coach scope applied by the clients router.
string
required
Client id.
string
required
Subscription id. Must belong to this trainee and studio.
date | null
Start date. Absent or
null means today in the studio’s timezone. The body itself is optional.start), inside one transaction:
- Loads the row and requires status
AWAITING_START. - Computes the end date from the duration stored on the row. If the row has none, the product is read again.
- Refuses if the span overlaps another live dated subscription.
- Sets the dates with a conditional update that only matches a row still waiting, so a trigger and a coach racing for the same row leave one winner. Status becomes
ACTIVE, orSCHEDULEDfor a future start. - Runs
reconcile(a back-dated start whose span already ran out ends asEXPIRED) andsyncMirror.
ClientActivity row with type SUBSCRIPTION_PLAN, action STARTED, the product id and plan name, and metadata of { "trigger": "manual" }. No push is sent and the assignment automation does not run again.
Response: the trainee’s live subscriptions after the start.
Errors:
NOT_FOUND: “client not found” or “subscription not found”.CONFLICT: “subscription is not waiting to start”, or the dates overlap a live subscription (withdetails.conflicts).BAD_REQUEST: the subscription has no duration, or the end does not land after the start.VALIDATION: body fails the schema.
POST /v1/web/clients/:clientId/subscriptions/:subscriptionId/cancel
Cancels a live subscription. No body.
Auth: web lane or bearer lane, any role, coach scope applied by the clients router.
string
required
Client id.
string
required
Subscription id. Must belong to this trainee and studio.
CANCELED and canceledOn is set to now. syncMirror then recomputes the client’s plan fields and status. A queued SCHEDULED row is not activated by this call. That happens on the next reconcile run.
Side effect: one ClientActivity row with type SUBSCRIPTION_PLAN, action REMOVED, the subscription id as entityId and the plan name as entityName. No push is sent.
Response: the trainee’s remaining live subscriptions.
Errors:
NOT_FOUND: “client not found” or “subscription not found”.VALIDATION: “subscription is not active” when the row is alreadyCANCELEDorEXPIRED.
Service functions used by other modules
createClientSubscriptionsService(repo, loadClient, notifier?, onAssigned?) is built in several route files. The notifier and the hook are optional, so a caller can leave out the push or the automation.
The service is also constructed in
crm.routes.ts, checkins.routes.ts, onboarding.routes.ts and partner.routes.ts, where it is passed into a clients service or partner service instance.
Subscription starter
subscription-starter.ts exports createSubscriptionStarter(ctx), which returns { onEvent(clientId, trigger, at?) }. It builds a service with no notifier and no hook and calls startByTrigger. The waiting row is started only when its startTrigger equals the event. Errors are caught and logged (“subscription start: trigger failed”), so the calling flow never fails because of it.
Where each trigger fires:
A trigger start records the same
STARTED activity row with metadata.trigger set to the trigger name. The actor type is TRAINEE for INTAKE_FORM and FIRST_WORKOUT, and TRAINER with no actor id for FIRST_PLAN.
Reconcile job
client-subscriptions.dispatcher.ts exports runSubscriptionReconcile(prisma, now). It finds every trainee with an ACTIVE row whose endsOn has passed or a SCHEDULED row whose startedOn has arrived, and runs reconcile for each in its own transaction. It returns { evaluated, reconciled }.
reconcile loops until nothing changes: ACTIVE rows past their end become EXPIRED, and when nothing is ACTIVE (not yet ended) or FROZEN, the first due SCHEDULED row becomes ACTIVE. Rows in AWAITING_START are never touched. When anything changed, syncMirror runs.
workers/subscription-reconcile-scheduler.ts registers this as a BullMQ repeatable job on the queue PerformQueue.SUBSCRIPTION_RECONCILE, repeating every hour with worker concurrency 1. The file’s comment says it also runs once on boot.
reconcile also runs inline at the start of addSubscription and freeze, and inside resync and a manual or trigger start.
Guards worth knowing
assertForwardSpan(startedOn, endsOn, timeZone)throwsBAD_REQUESTwhen a subscription would end before it starts. Equal days are allowed, becausecreateInitialusesendsOnequal tostartedOnas the legacy “no end date recorded” shape.createInitialgives a new rowFROZENwhen the client hasplanFrozenOn,SCHEDULEDwhen the start day has not arrived,EXPIREDwhenendsOnhas passed, andACTIVEotherwise. When the client has noendsOn, the end is set to the start.replaceActiveonly creates a new row when the client has aproductId, astartedOnand anendsOn.