Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPlatform BillingPlatform Billing

Platform Billing

FitKit’s own SaaS subscription rail — the monthly fee the gym owner pays FitKit for access to Pro / Elite tiers.

What & why

This is the platform’s subscription, distinct from the per-org member billing in payments/. A single shared Cardcom terminal (PLATFORM_BILLING_* env) is owned by FitKit and used to charge every gym’s monthly subscription. Each org has one platform_billing_subscriptions row tracking its tier, billing cycle, stored Cardcom token, and failure counter.

Persona impact:

PersonaCapability
OwnerView tier + billing status, start checkout, cancel at period end, resume cancellation.
Admin / CoachRead-only via tier endpoint.
FitKit adminCancel + downgrade via DB / admin API.
MembersNone — this is invisible to members.

The platform fee model

FitKit operates on fixed monthly tier fees, not a percentage of GMV. Three tiers in libs/shared/src/lib/constants/platform-tiers.ts:

TierMonthly (ILS)Max membersMax coachesMax locations
lite0 (free)3021
pro249150101
elite499unlimitedunlimitedunlimited

The gym keeps 100% of member payments. FitKit takes no cut of plan purchases, class packs, drop-ins, or courses today (FIT-147 / FIT-148 are the open issues to introduce a course revenue share). The platform fee is the only money FitKit collects on the production rail.

See docs/_archive/business/cost-analysis.md for the commercial rationale.

Capabilities

  • Cardcom hosted-page checkout from the Lite → Pro / Elite upgrade dialog.
  • Stored-token recurring re-charge driven by a daily cron (Cardcom has no native recurring deals).
  • Cancel-at-period-end + immediate-cancel paths.
  • Cardcom webhook + verify-on-return + 5-minute reconciler — three converging paths so a paid checkout never gets lost.
  • Per-org tier flips on every state change (organizations.platform_tier is the source of truth for feature gating).

How the tier gate works

PlatformTierGuard (apps/api/src/platform-tiers/platform-tier.guard.ts).

  1. Reads @RequiresFeature(...features) metadata from the handler/class.
  2. Loads organizations.platform_tier for the :orgId URL param.
  3. tierHasFeature(tier, feature) for each required feature; throws 403 on the first miss.
  4. Attaches request.platformTier and request.platformTierConfig for downstream handlers.

Tier → feature mapping (libs/shared/src/lib/constants/platform-tiers.ts:53):

FeatureLiteProElite
membership_plans, basic_workouts, csv_import
class_packs, automated_billing, minisite, custom_domain, bulk_messaging, lead_management, workout_builder, result_logging, community_feed, qr_checkin, leaderboards, messaging, analytics_dashboard, arbox_migration, gps_checkin, email_automations, courses
multiple_locations, whatsapp_bot, whatsapp_automations, advanced_analytics, custom_reports

Gated routes (sampled from grep):

ModuleFeature gate
plans/ (CRUD)membership_plans (Lite+)
exercises/basic_workouts (Lite+)
workout-results/basic_workouts (Lite+)
courses/ (CRUD + lifecycle)courses (Pro+)
workouts/ (builder mutations)workout_builder (Pro+) — service-level via tierHasFeature

The guard fires before the handler. The same tierHasFeature helper is used at the service layer for non-route enforcement (e.g. plans.service.ts:384 when creating a class_pack plan).

Capabilities — concrete flows

Upgrade Lite → Pro/Elite

  1. Owner clicks “Upgrade” → POST /organizations/:orgId/billing/checkout { tier }.
  2. createCheckoutSession (platform-billing.service.ts:126):
    • Verifies role = owner.
    • Acquires per-org advisory lock to serialise concurrent checkout calls.
    • Reuses a recent (within 10 minutes) matching pending checkout if one exists.
    • Cancels stale pending rows for other tiers.
    • Calls Cardcom LowProfile/Create with ChargeAndCreateToken (token captured for recurring re-charge).
    • Inserts platform_billing_transactions row (status='pending').
  3. Buyer pays on Cardcom hosted page.
  4. Cardcom IPN → POST /webhooks/platform-billing/cardcom:
    • Re-fetches truth via getTransactionStatus (we never trust the IPN body).
    • On completed: handlePaymentCompleted (:281) — flips txn to completed, upserts platform_billing_subscriptions (status=active, token+card stored, period set to now + 1 month), flips organizations.platform_tier.
    • On failed: handlePaymentFailed — increments failed_payment_count, cancels sub if count ≥ 3, downgrades org to lite.
  5. Buyer returns to …/dashboard/settings/checkout-return → calls POST /billing/verify-checkout as fallback (platform-billing.service.ts:501) which re-runs the same logic if the webhook hasn’t fired.

Monthly renewal

PlatformBillingRecurringService.tick — daily 02:00 UTC (platform-billing-recurring.service.ts:98).

  • Picks subs whose currentPeriodEnd ≤ now + 1h (lead-time so a brief Cardcom outage doesn’t gap the service).
  • For each row:
    • cancelAtPeriodEnd && currentPeriodEnd ≤ now → finalize cancel via cancelSubscriptionInternal.
    • No stored providerToken && period elapsed → flip to lite (no recurring possible).
    • Otherwise call Cardcom ChargeWithToken; success advances the period, failure increments failed_payment_count and may cancel.

Cancellation

PathOutcome
Owner clicks “Cancel” → POST /billing/cancelcancelAtPeriodEnd=true. Org keeps paid tier through currentPeriodEnd. Reversible via /billing/resume.
3 failed renewalscancelSubscriptionInternal('max-failed-payments') — immediate cancel, org → Lite.
Admin force-cancelcancelSubscriptionInternal('admin') — immediate.
PATCH /tier { tier: 'lite' } from a paid tierRoutes through cancelSubscription (platform-tiers.controller.ts:94); same period-end semantics.
  • payments/ — same Cardcom adapter (CardcomProvider), different DB schema. Shares the credentials encryption service.
  • platform-tiers/ — tier read/write + @RequiresFeature decorator.
  • organizations/ — owns the platform_tier column flipped by this module.

Status

Production. Cardcom production terminal in use (see docs/_archive/plans/cardcom-production-terminal.md). Reconciler running every 5 minutes; safety net for missed webhooks.

Gaps

  • FIT-147 / FIT-148 — course revenue share. The platform fee model is fixed tier today; introducing a percentage cut on plan.type='course' purchases is the open commercial work.
  • provider_token is plaintextlibs/db/src/lib/schema/platform-billing.ts:62 declares it text, while per-org member_payment_methods.encrypted_token uses AES-GCM. Inconsistent. Should be re-keyed.
  • Single shared terminal — every gym’s platform fee runs through one Cardcom terminal. If FitKit needs to split orgs across terminals (e.g. for compliance or multi-entity), the credentials provider is hardcoded to env.
  • No proration on tier upgrade mid-cycle — paid → paid downgrade is explicitly unsupported (must cancel + re-checkout, see platform-tiers.controller.ts:88); upgrades pay full new-tier price immediately and the period resets.
  • Reconciler hang alerts dedupe by metadata.alertedAt but the schema has no compound index; a backlog of 25 alerts/min can still slip past dedupe if the metadata roundtrip lags.