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:
| Persona | Capability |
|---|---|
| Owner | View tier + billing status, start checkout, cancel at period end, resume cancellation. |
| Admin / Coach | Read-only via tier endpoint. |
| FitKit admin | Cancel + downgrade via DB / admin API. |
| Members | None — 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:
| Tier | Monthly (ILS) | Max members | Max coaches | Max locations |
|---|---|---|---|---|
lite | 0 (free) | 30 | 2 | 1 |
pro | 249 | 150 | 10 | 1 |
elite | 499 | unlimited | unlimited | unlimited |
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_tieris the source of truth for feature gating).
How the tier gate works
PlatformTierGuard (apps/api/src/platform-tiers/platform-tier.guard.ts).
- Reads
@RequiresFeature(...features)metadata from the handler/class. - Loads
organizations.platform_tierfor the:orgIdURL param. tierHasFeature(tier, feature)for each required feature; throws 403 on the first miss.- Attaches
request.platformTierandrequest.platformTierConfigfor downstream handlers.
Tier → feature mapping (libs/shared/src/lib/constants/platform-tiers.ts:53):
| Feature | Lite | Pro | Elite |
|---|---|---|---|
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):
| Module | Feature 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
- Owner clicks “Upgrade” →
POST /organizations/:orgId/billing/checkout { tier }. 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
pendingcheckout if one exists. - Cancels stale
pendingrows for other tiers. - Calls Cardcom
LowProfile/CreatewithChargeAndCreateToken(token captured for recurring re-charge). - Inserts
platform_billing_transactionsrow (status='pending').
- Buyer pays on Cardcom hosted page.
- Cardcom IPN →
POST /webhooks/platform-billing/cardcom:- Re-fetches truth via
getTransactionStatus(we never trust the IPN body). - On
completed:handlePaymentCompleted(:281) — flips txn tocompleted, upsertsplatform_billing_subscriptions(status=active, token+card stored, period set tonow + 1 month), flipsorganizations.platform_tier. - On
failed:handlePaymentFailed— incrementsfailed_payment_count, cancels sub if count ≥ 3, downgrades org tolite.
- Re-fetches truth via
- Buyer returns to
…/dashboard/settings/checkout-return→ callsPOST /billing/verify-checkoutas 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 viacancelSubscriptionInternal.- No stored
providerToken&& period elapsed → flip tolite(no recurring possible). - Otherwise call Cardcom
ChargeWithToken; success advances the period, failure incrementsfailed_payment_countand may cancel.
Cancellation
| Path | Outcome |
|---|---|
Owner clicks “Cancel” → POST /billing/cancel | cancelAtPeriodEnd=true. Org keeps paid tier through currentPeriodEnd. Reversible via /billing/resume. |
| 3 failed renewals | cancelSubscriptionInternal('max-failed-payments') — immediate cancel, org → Lite. |
| Admin force-cancel | cancelSubscriptionInternal('admin') — immediate. |
PATCH /tier { tier: 'lite' } from a paid tier | Routes through cancelSubscription (platform-tiers.controller.ts:94); same period-end semantics. |
Related features
payments/— same Cardcom adapter (CardcomProvider), different DB schema. Shares the credentials encryption service.platform-tiers/— tier read/write +@RequiresFeaturedecorator.organizations/— owns theplatform_tiercolumn 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_tokenis plaintext —libs/db/src/lib/schema/platform-billing.ts:62declares ittext, while per-orgmember_payment_methods.encrypted_tokenuses 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.alertedAtbut the schema has no compound index; a backlog of 25 alerts/min can still slip past dedupe if the metadata roundtrip lags.