Feature Gating & Subscription Tiers
Feature gating styrer hva brukeren kan se og gjøre basert på abonnementsnivå. Arkitekturen skiller mellom to uavhengige lag: feature-synlighet (klient) og kvotehåndhevelse (server).
To-lags modell
heading.anchorLabel┌─────────────────────────────────────────────────────────┐│ iOS / Android ││ ││ FeatureGate.kt (KMP) QuotaStoreWrapper ││ ───────────────────── ────────────────── ││ "Skal jeg VISE dette?" "Kan jeg GJØRE dette?" ││ Basert på tier-streng Basert på API-svar ││ Ren logikk, ingen API Synkronisert med server ││ ││ hasMasteryFeatures("mastery") canCapture → true/false ││ requiresUpgrade("trial", canCheckin → true/false ││ "mastery") canStartSession → true │└──────────────────────────────┬──────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────┐│ API (Railway) ││ ││ impulse-quota-service.ts ││ ───────────────────────── ││ isSubscriptionActive() → binær: active eller ikke ││ canCaptureImpulse() → kvotesjekk + tilgang ││ canCreateCheckin() → kvotesjekk + tilgang ││ canStartSession() → kvotesjekk + tilgang ││ ││ subscription_config DB → kvoter konfigurerbare uten ││ redeploy │└─────────────────────────────────────────────────────────┘| Lag | Ansvar | Kilde | Eksempel |
|---|---|---|---|
| Feature visibility | Skal UI-element vises/låses? | FeatureGate.kt (tier-streng) | Breakthroughs kun for mastery |
| Quota enforcement | Kan bruker utføre handling? | impulse-quota-service.ts (API) | 3 impulser på trial |
Tier-hierarki
heading.anchorLabeltrial (0) < foundation (1) = partner (1) < mastery (2)| Tier | Rang | Pris | Impulser | Sjekk-inn | Mastery-features |
|---|---|---|---|---|---|
| Trial | 0 | Gratis 7 dager | 3 totalt | 7 totalt | Nei |
| Foundation (Impulse) | 1 | $6/mnd | 30/mnd | 90/mnd | Nei |
| Partner | 1 | Gratis | 30/mnd | 90/mnd | Nei |
| Mastery (Pro) | 2 | $12/mnd | 60/mnd (∞) | 180/mnd (∞) | Ja |
Trial-brukere ser alle UI-elementer (full visibility), men handlinger er kvotebegrenset. Etter trial-utløp vises paywall.
KMP FeatureGate
heading.anchorLabelFil: shared/src/commonMain/kotlin/com/im/shared/models/FeatureGate.kt
Ren Kotlin object — ingen state, ingen store, ingen API-kall. Tar en tier-streng og returnerer booleans.
object FeatureGate { fun tierRank(tier: String): Int = when (tier) { "trial" -> 0 "foundation" -> 1 "partner" -> 1 "mastery" -> 2 else -> 0 }
fun hasMasteryFeatures(tier: String): Boolean = tier == "mastery"
fun requiresUpgrade(currentTier: String, requiredTier: String): Boolean = tierRank(currentTier) < tierRank(requiredTier)
fun hasFullVisibility(tier: String): Boolean = true}iOS-integrasjon
heading.anchorLabelQuotaStoreWrapper.swift eksponerer FeatureGate som computed properties:
var tier: String { quotaStatus?.tier ?? "trial" }var hasMasteryFeatures: Bool { FeatureGate.shared.hasMasteryFeatures(tier: tier) }func requiresUpgrade(to targetTier: String) -> Bool { FeatureGate.shared.requiresUpgrade(currentTier: tier, requiredTier: targetTier)}Views bruker dette direkte:
// InsightsView — vise/skjule breakthroughsprivate var isMastery: Bool { KMPStoreProvider.shared.quotaStore.hasMasteryFeatures}Legge til ny feature-gate
heading.anchorLabel-
Legg til funksjon i
FeatureGate.kt:fun hasAdvancedAnalytics(tier: String): Boolean =tierRank(tier) >= 2 -
Legg til computed property i
QuotaStoreWrapper.swift:var hasAdvancedAnalytics: Bool {FeatureGate.shared.hasAdvancedAnalytics(tier: tier)} -
Bruk i view:
if quotaStore.hasAdvancedAnalytics {AdvancedAnalyticsCard()}
Legge til nytt tier
heading.anchorLabel- Oppdater
FeatureGate.kt— legg til itierRank()switch - Oppdater
subscription-tiers.ts— legg til iTIER_HIERARCHY - Oppdater
QuotaStatus.kt— legg til itierDisplayLabel - Oppdater
subscription_configDB-tabell — legg til kvoter
Binær tilgangsmodell
heading.anchorLabelAbonnement er binært — enten aktiv eller ikke. Ingen mellomtilstander.
isSubscriptionActive(): trial → trial_ends_at > nå foundation → status === "active" mastery → status === "active" cancelled → subscription_end_date > nå partner → status === "active" alt annet → false → paywallApple/Google grace period
heading.anchorLabelApp Store og Google Play håndterer grace period og billing retry på sin side. Vi mottar webhook-events som oppdaterer iap_subscriptions-tabellen, men disse statusene påvirker ikke brukerens tilgang.
| Status | Hvor | Påvirker tilgang? |
|---|---|---|
active | profiles + iap_subscriptions | Ja — aktiv |
expired | profiles + iap_subscriptions | Ja — paywall |
cancelled | profiles + iap_subscriptions | Ja — aktiv til end_date |
grace_period | Kun iap_subscriptions | Nei — kun audit-data |
billing_retry | Kun iap_subscriptions | Nei — kun audit-data |
paused | Kun iap_subscriptions (Android) | Nei — kun audit-data |
Webhook skriver grace_period → profiles.subscription_status forblir active → bruker beholder tilgang → Apple sender EXPIRED → status settes til expired → paywall.
Kvotehåndhevelse (server-side)
heading.anchorLabelKvoter håndheves utelukkende server-side via impulse-quota-service.ts. Klienten viser kvote-status men kan ikke omgå den.
Konfigurering uten redeploy
heading.anchorLabelKvoter lagres i subscription_config-tabellen:
| Tier | Impulser | Periode | Sjekk-inn | Periode | Trial-dager |
|---|---|---|---|---|---|
| trial | 3 | total | 7 | total | 7 |
| foundation | 30 | monthly | 90 | monthly | — |
| mastery | 60 | monthly | 180 | monthly | — |
| partner | 30 | monthly | 90 | monthly | — |
Endre verdier i Supabase Studio → Redis-cache utløper etter 5 min → nye kvoter aktive.
Kvote-API
heading.anchorLabelGET /api/v1/quota/status
{ "tier": "foundation", "status": "active", "can_capture": true, "can_checkin": true, "can_start_session": true, "impulses": { "used": 12, "limit": 30, "period": "monthly" }, "checkins": { "used": 5, "limit": 90, "period": "monthly" }, "marketed_as_unlimited": false}Filreferanser
heading.anchorLabel| Komponent | Fil |
|---|---|
| FeatureGate (KMP) | shared/.../models/FeatureGate.kt |
| QuotaStatus (KMP) | shared/.../models/QuotaStatus.kt |
| QuotaStore (KMP) | shared/.../stores/quota/QuotaStore.kt |
| QuotaStoreWrapper (iOS) | iosApp/Stores/QuotaStoreWrapper.swift |
| Kvote-service (API) | apps/api/src/services/impulse-quota-service.ts |
| Subscription middleware (API) | apps/api/src/middleware/subscription.ts |
| Tier-konstanter (TS) | packages/shared/src/constants/subscription-tiers.ts |
| Subscription-typer (TS) | packages/shared/src/types/subscription.ts |
| Kvote-konfig (DB) | subscription_config-tabellen |
| Paywall (iOS) | iosApp/Features/Subscription/SubscriptionPaywallView.swift |
Relatert
heading.anchorLabel- Autentisering og abonnement — sign-in, JWT, IAP sync
- State Machines — status-overganger for impulser og sesjoner