Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesCoursesCourses

Courses

On-demand digital courses with day-by-day curriculum, a public storefront, an entitlement-based player, and a Clerk email-code checkout flow.

What & why

A course is a programs row with delivery_mode='course' plus a 1:1 course_configs row carrying the course-shaped metadata (duration, preview policy, publish status). The curriculum is a denormalised list of course_workouts (day-by-day, multiple workouts per day, explicit rest days). Buyers get a course_entitlements row keyed on user_id (not membership) so a course follows the human across orgs and survives churn.

Persona impact:

PersonaCapability
Trainer (owner/admin/coach)Create / publish / archive courses, edit curriculum, set price, view per-buyer progress.
Buyer (any Clerk user)Browse public storefront, complete checkout via Clerk email-code, access library cross-org, follow day-by-day curriculum, restart.
Org ownerTier-gated behind courses feature (Pro+). Sees course buyers in the leads list for category-B sales.

Capabilities

  • Day-by-day curriculum with rest days. course_workouts(program_id, day_offset, sort_order) — multiple workouts per day, ordered.
  • Preview policies: none | first_day | first_week | specific_workout. Public storefront surfaces the preview.
  • Publish lifecycle: draft → published → archived. Archived courses retain access for existing buyers.
  • Entitlements: 1:1 with (user_id, program_id). Status pending → active | revoked. Free courses jump straight to active; paid courses are activated by the payment webhook.
  • Two buyer categories:
    • A — buyer is already a member of the seller org. course_entitlements.membership_id set.
    • B — buyer is not a member. A lead row is created with source='course_purchase'. membership_id stays null.
  • Player UX: startDate set when the buyer hits “Start”. Day n derived from floor((today - startDate) / 1d). Restart rewrites startDate and clears completions.
  • Idempotent completions: course_workout_completions unique on (entitlement_id, course_workout_id). Mark = INSERT … ON CONFLICT DO NOTHING; unmark = DELETE. Course-level “completed” is derived (every non-rest curriculum row has a completion).
  • Trainer drill-down: per-buyer progress (listEnrollments), per-day completion stats (loadDayCompletionStats).
  • Buyer library is org-agnostic: GET /me/courses and GET /me/courses/:id ignore the active organization.

Platform fee (status)

0% today. FitKit takes no cut of course purchases — the gym keeps 100%. The course plans row has the same shape as a subscription plan, payment flows through the same gym-owned provider config, and money lands in the gym’s account.

FIT-147 / FIT-148 is the open commercial work to introduce a percentage cut on courses. When implemented, it will likely:

  • Re-route the course checkout through the FitKit platform terminal (not the gym’s provider), or
  • Add a platform_fee_in_cents column on payment_transactions and split out a payout to the gym, or
  • Apply at the invoicing layer rather than the gateway.

Until then, course pricing should never be quoted to the gym with a fee assumption.

Tier gate

@RequiresFeature('courses') on every trainer-side CRUD route (apps/api/src/courses/courses.controller.ts:46, :198, :212, :226, :240). The courses feature is Pro+ (see platform-billing/README.md). Lite gyms cannot author or publish courses, but they can be buyers of someone else’s course (the buyer side is unrestricted — me/courses/* and the public storefront are not tier-gated).

Capabilities — concrete

CapabilityWhere
Create coursePOST /organizations/:orgId/courses
List org coursesGET /organizations/:orgId/courses
Set day curriculumPOST /organizations/:orgId/courses/:id/days
Set pricePATCH /organizations/:orgId/courses/:id/price
PublishPOST /organizations/:orgId/courses/:id/publish (requires at least one curriculum entry)
ArchiveDELETE /organizations/:orgId/courses/:id (existing buyers retain access)
Public detailGET /public/courses/:id
CheckoutPOST /public/courses/:id/checkout (@Public — Clerk session enforced in handler)
My libraryGET /me/courses
My entitlementGET /me/courses/:id
Start coursePOST /me/courses/:id/start { startDate? }
RestartPOST /me/courses/:id/restart
Read workoutGET /me/courses/:id/workouts/:workoutId
Mark completePOST /me/courses/:id/workouts/:workoutId/complete
UnmarkDELETE /me/courses/:id/workouts/:workoutId/complete
Per-buyer progressGET /organizations/:orgId/courses/:id/enrollments
  • payments/ — checkout uses PaymentService.createCourseHostedPayment; webhook activates the entitlement.
  • subscriptions-plans/ — course-type plans share the plans table with plan.type='course' and plan.program_id set.
  • programs/ (scheduling/) — the underlying programs row.
  • workouts/ — curriculum workouts come from the org’s workout library.
  • leads-crm/ — category-B buyers become leads (source='course_purchase').
  • minisites/ — public storefront is rendered from the minisite domain.

Status

Production. Email-code Clerk checkout in production. Free + paid courses both supported. Course revenue share is the open commercial question.

Gaps

  • FIT-147 / FIT-148 — platform fee on courses (currently 0%).
  • No mid-course refund flow specifically modelled. Refunds go through payments/; revoking the entitlement is handled in webhook-processing.service.ts:353 only for failed payments. A successful charge refunded later does NOT auto-revoke the entitlement (the gym must manually do so, no UI exists).
  • Cross-org entitlement display — the player works, but the dashboard shell (“active org”) doesn’t know which org the buyer should see “as” for a course. UX hack: header reads entitlement.organization.name directly.
  • No course-level “lifetime access” alternatives. accessPolicy column exists with 'lifetime' only (courses.ts:31); future policies (duration_x_2, fixed_window) are not implemented.
  • Buyer with no email cannot purchase. attachLeadForBuyer requires user.email (courses.service.ts:942). Clerk session always has one in practice.
  • Republish doesn’t notify previous buyers. Archive → re-publish is silent.