Subscription, Tiers & Feature Gating
Tier definitions (free/plus/premium), TIER_FEATURES map, FeatureGate contract, server-side enforcement, Stripe integration, Supabase schema.
Status: Current as of 2026-04-18 (repo commit 18f5daa).
Audience: New contributors, BfArM reviewer (secondary — access control is a compliance surface).
Source of truth for code: apps/web/lib/subscription/.
1. Tier definitions
Three tiers, cumulative (Plus inherits Free, Premium inherits Plus + Free). Prices from TIERS in apps/web/lib/subscription/types.ts.
| Tier | Price | Positioning |
|---|---|---|
free | Kostenlos | Acquisition + evidence base |
plus | 9,90 €/mo | Retention + conversion |
premium | 19,90 €/mo | High-value + B2B lever |
Cumulative semantics are enforced by hasFeature():
// apps/web/lib/subscription/types.ts (excerpt)
export function hasFeature(tier: SubscriptionTier, feature: Feature): boolean {
const tiers: SubscriptionTier[] = ["free", "plus", "premium"];
const tierIndex = tiers.indexOf(tier);
for (let i = 0; i <= tierIndex; i++) {
if (TIER_FEATURES[tiers[i]].includes(feature)) return true;
}
return false;
}2. Feature-to-tier mapping
Authoritative list from TIER_FEATURES in apps/web/lib/subscription/types.ts. 16 features total.
Free (7 features)
| Feature key | User-visible label |
|---|---|
chat_unlimited | Julia AI Chat (unlimited, anonymous) |
lexikon | Lexikon |
magazin | Magazin |
rezepte | Rezepte |
community_read | Read community |
community_post_limited | Limited posting |
chat_history_7d | 7-day chat history |
Plus (+5 features, total 12)
| Feature key | User-visible label |
|---|---|
chat_history_full | Full chat history |
arztbrief_simplify | Arztbrief simplification |
pdf_export | PDF export |
document_storage | Document storage (master.md) |
community_full | Full community (post + comment) |
Premium (+4 features, total 16)
| Feature key | User-visible label | Notes |
|---|---|---|
breastfriend_matching | Peer matching | 1:1 wizard flow |
klinik_finder | Klinik-Finder | DKG-qualified breast centers |
studien_matching | Studien-Matching | Clinical trial matching |
behandlungszeitstrahl | Behandlungszeitstrahl | Therapy timeline |
Do not duplicate the list here when it changes. Update
TIER_FEATURESin code; this doc must mirror it on next touch. Thetypes.test.tssuite should catch drift, but it won't catch a missing doc update.
3. Enforcement surfaces
Client-side gate — FeatureGate component
File: apps/web/lib/subscription/gate.tsx.
Typical use:
import { FeatureGate } from "@/lib/subscription/gate";
<FeatureGate feature="document_storage" fallback={<UpgradeCTA />}>
<DocumentList />
</FeatureGate>Under the hood, it reads from the subscription context provided by apps/web/lib/subscription/context.tsx (useSubscription() hook).
Limitation: Client-side gates are a UX affordance, not a security boundary. Anyone can bypass by calling the API directly. Every feature that protects data MUST also be enforced server-side (§3.2).
Server-side enforcement — checkFeatureAccess
File: apps/web/lib/subscription/server.ts.
const access = await checkFeatureAccess(supabase, userId, "document_storage");
if (!access.allowed) return new Response("Forbidden", { status: 403 });Return shape:
{ allowed: true; tier: SubscriptionTier } |
{ allowed: false; tier: SubscriptionTier }Note: the function returns the current tier, not the required tier. The caller already knows which feature they passed in; requiredTier(feature) in types.ts is the helper for upgrade-CTA copy.
Current call site (verified): apps/web/app/api/chat/route.ts gates master.md injection on document_storage. The 10 routes under apps/web/app/api/documents/* should all gate on the same feature — review as part of the audit-wiring pass (see audit-system §7).
Supabase lookup
// apps/web/lib/subscription/server.ts (getUserTier)
const { data: sub } = await supabase
.from("subscriptions")
.select("tier")
.eq("user_id", userId)
.eq("status", "active")
.single();
return (sub?.tier as SubscriptionTier) || "free";"No row" or "inactive" falls back to free. This is safe-default behavior (deny paid features, allow free features).
4. Stripe integration
Price IDs from env
STRIPE_PRICE_PLUS_MONTHLYSTRIPE_PRICE_PREMIUM_MONTHLY
Exact mapping lives in apps/web/lib/stripe.ts (or similar — verify path when touching). Never hardcode price IDs in route handlers.
Flow (read from code, verify before changing)
User clicks "Upgrade" → /api/stripe/checkout → Stripe Checkout Session
↓
User completes payment → Stripe webhook → /api/stripe/webhook
↓
Upsert into public.subscriptions (status='active', tier)
↓
getUserTier() now returns 'plus' or 'premium'Customer portal for self-service cancellation / upgrade lives at /api/stripe/portal (or similar).
Webhook security: Stripe signs webhook bodies. Verify Stripe-Signature header against STRIPE_WEBHOOK_SECRET before processing. Do not trust the event payload without signature verification.
5. Supabase schema
Schema created in supabase/migrations/20260402_monetization_schema.sql. Two tables relevant here:
| Table | Purpose |
|---|---|
subscriptions | Per-user tier + Stripe metadata. getUserTier() reads this. |
community_post_counts | Supports the community_post_limited feature (counts enforced via DB function can_community_post(), not via checkFeatureAccess). |
A subsequent migration 20260407_community_post_limit_2.sql tunes the post-limit logic. The division is deliberate:
- Binary access (can user X use feature Y?) →
checkFeatureAccess+TIER_FEATURES - Quantitative limits (how many posts today?) → DB functions returning booleans
Don't try to express "10 posts per day" as a feature flag. It belongs in the DB.
6. Gaps + pitfalls
Caught during prior research, still open as of 2026-04-18:
pdf_exporthas no visibleFeatureGatewrapper. The backend route should still checkcheckFeatureAccess, but UX-level gating may be inconsistent. Review PDF export page.chat_history_7dvschat_history_full— enforced via a custom hook, notFeatureGate. This is fine but means the pattern is split; document any migration before unifying.- Community post limits use the DB function path (see §5) — neither the gate component nor
checkFeatureAccessis involved. If you change tier→limit mapping, check both the TS map and the SQL function. - No "trial" state today.
subscriptions.statusis binary (active / not-active). Stripe offers trials; the webhook currently maps all statuses other thanactiveto free fallback. - Admin override. No documented path for admin to grant premium access without Stripe (e.g., partner clinics). If needed, design and document before adding.
7. How to add a new feature
Minimal checklist when introducing a gated capability:
- Add to
Featureunion type intypes.ts - Add to the relevant tier in
TIER_FEATURES - Add a visible entry in the matching
TIERS[].featuresarray (for the pricing page) - Wrap the UI with
<FeatureGate feature="your_feature" fallback={...}> - Gate the backend route with
checkFeatureAccess(supabase, userId, "your_feature") - Update
types.test.tsexpectations - Update this doc's §2 table on the same PR
8. Tests
Existing coverage:
| File | Covers |
|---|---|
apps/web/lib/subscription/types.test.ts | TIER_FEATURES shape, hasFeature, requiredTier, TIERS array |
apps/web/lib/subscription/server.test.ts | getUserTier, checkFeatureAccess with mocked Supabase |
apps/web/lib/subscription/route-access.test.ts | Route-level gate wiring |
Run them before touching this surface. They are the cheapest way to catch a regression.
9. Related
| # | Relevance |
|---|---|
| #579 | Parent docs epic |
| #586 | Pillar B parent |
| #588 / PR #590 | architecture — references this for the document-context gate |
| #589 / PR #591 | audit-system — §7 should eventually include subscription.tier_changed event |
apps/web/lib/subscription/ | Source of truth |
supabase/migrations/20260402_monetization_schema.sql | Schema |
Changelog
- 2026-04-18 — Initial version. TIER_FEATURES map and prices verified against
types.ts.checkFeatureAccessreturn shape noted (norequiredTierfield — quilt §1 was slightly off). Four test files in subscription/ confirmed.
Audit Logging System
DiGAV §4(3) / DSGVO Art. 9 / Art. 5(2) design doc — audit_logs schema, RLS policies, retention, implementation priority. Zero audit infra exists today.
Configuration System
Env var inventory across web (Vercel), voice (EC2), and mobile (EAS). Security classification, cron definition, rotation runbook, known gaps.