A 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 in modules/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.
Freeze and reactivate are part of this module’s service but are exposed as POST /v1/web/clients/:id/freeze and POST /v1/web/clients/:id/reactivate. They are documented on the Clients page.

Data model

Fields of ClientSubscription (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):
  1. The studio’s start mode is not ASSIGNMENT.
  2. The caller sent no start date.
  3. The trainee has no live subscription.
  4. The trainee has not already done the event. INTAKE_FORM checks for a FormResponse to a form of type INTAKE. FIRST_PLAN checks for a program that is not DRAFT and is not a PAUSED program with releasePending. FIRST_WORKOUT checks for a completed WorkoutLog.
Otherwise the subscription gets dates right away.

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_RISK and ARCHIVED are left as they are.
  • A FROZEN subscription gives PAUSED.
  • An ACTIVE subscription, or any scheduled or waiting one, gives ACTIVE.
  • Nothing live gives CHURNED.
With no live row left, the plan fields are cleared and 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.
Response: an array of ClientSubscription rows.
Errors: 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.
What the service does (addSubscription), inside one transaction:
  1. Runs reconcile for the trainee, so due rows are expired or activated first.
  2. Resolves the duration: body first, then product.
  3. Refuses if the trainee already has an AWAITING_START row.
  4. Decides whether the new row waits (see “Start mode”).
    • Waiting: creates the row with status AWAITING_START, no dates, and the trigger in startTrigger.
    • 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 is ACTIVE when the start day has arrived and nothing is ACTIVE or FROZEN, otherwise SCHEDULED.
  5. Runs syncMirror.
After the transaction:
  • Automation hook. subscriptionAssignedHook runs with { studioId, clientId, subscriptionId, productId, planName }. It starts the SUBSCRIPTION_ASSIGNED task 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 ClientActivity row with type SUBSCRIPTION_PLAN, action ASSIGNED, entity type SUBSCRIPTION, the product id as entityId and the product name as entityName.
  • Push. A SUBSCRIPTION_RENEWED notification to the trainee with the vars plan (from traineePlanName(product)) and studio.
Response: the trainee’s live subscriptions after the change, same shape as the list endpoint. Errors:
  • 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.conflicts lists each blocking row.
  • VALIDATION: body fails the schema.
The message of an overlap names the blocking plan, its status and its days, and ends with what to do. details.conflicts entries look like this:
For a waiting row, 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.
What the service does (start), inside one transaction:
  1. Loads the row and requires status AWAITING_START.
  2. Computes the end date from the duration stored on the row. If the row has none, the product is read again.
  3. Refuses if the span overlaps another live dated subscription.
  4. 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, or SCHEDULED for a future start.
  5. Runs reconcile (a back-dated start whose span already ran out ends as EXPIRED) and syncMirror.
Side effect: one 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 (with details.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.
The row’s status becomes 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 already CANCELED or EXPIRED.

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) throws BAD_REQUEST when a subscription would end before it starts. Equal days are allowed, because createInitial uses endsOn equal to startedOn as the legacy “no end date recorded” shape.
  • createInitial gives a new row FROZEN when the client has planFrozenOn, SCHEDULED when the start day has not arrived, EXPIRED when endsOn has passed, and ACTIVE otherwise. When the client has no endsOn, the end is set to the start.
  • replaceActive only creates a new row when the client has a productId, a startedOn and an endsOn.