fragJulia
Dev

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.

TierPricePositioning
freeKostenlosAcquisition + evidence base
plus9,90 €/moRetention + conversion
premium19,90 €/moHigh-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 keyUser-visible label
chat_unlimitedJulia AI Chat (unlimited, anonymous)
lexikonLexikon
magazinMagazin
rezepteRezepte
community_readRead community
community_post_limitedLimited posting
chat_history_7d7-day chat history

Plus (+5 features, total 12)

Feature keyUser-visible label
chat_history_fullFull chat history
arztbrief_simplifyArztbrief simplification
pdf_exportPDF export
document_storageDocument storage (master.md)
community_fullFull community (post + comment)

Premium (+4 features, total 16)

Feature keyUser-visible labelNotes
breastfriend_matchingPeer matching1:1 wizard flow
klinik_finderKlinik-FinderDKG-qualified breast centers
studien_matchingStudien-MatchingClinical trial matching
behandlungszeitstrahlBehandlungszeitstrahlTherapy timeline

Do not duplicate the list here when it changes. Update TIER_FEATURES in code; this doc must mirror it on next touch. The types.test.ts suite 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_MONTHLY
  • STRIPE_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:

TablePurpose
subscriptionsPer-user tier + Stripe metadata. getUserTier() reads this.
community_post_countsSupports 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:

  1. pdf_export has no visible FeatureGate wrapper. The backend route should still check checkFeatureAccess, but UX-level gating may be inconsistent. Review PDF export page.
  2. chat_history_7d vs chat_history_full — enforced via a custom hook, not FeatureGate. This is fine but means the pattern is split; document any migration before unifying.
  3. Community post limits use the DB function path (see §5) — neither the gate component nor checkFeatureAccess is involved. If you change tier→limit mapping, check both the TS map and the SQL function.
  4. No "trial" state today. subscriptions.status is binary (active / not-active). Stripe offers trials; the webhook currently maps all statuses other than active to free fallback.
  5. 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 Feature union type in types.ts
  • Add to the relevant tier in TIER_FEATURES
  • Add a visible entry in the matching TIERS[].features array (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.ts expectations
  • Update this doc's §2 table on the same PR

8. Tests

Existing coverage:

FileCovers
apps/web/lib/subscription/types.test.tsTIER_FEATURES shape, hasFeature, requiredTier, TIERS array
apps/web/lib/subscription/server.test.tsgetUserTier, checkFeatureAccess with mocked Supabase
apps/web/lib/subscription/route-access.test.tsRoute-level gate wiring

Run them before touching this surface. They are the cheapest way to catch a regression.


#Relevance
#579Parent docs epic
#586Pillar B parent
#588 / PR #590architecture — references this for the document-context gate
#589 / PR #591audit-system — §7 should eventually include subscription.tier_changed event
apps/web/lib/subscription/Source of truth
supabase/migrations/20260402_monetization_schema.sqlSchema

Changelog

  • 2026-04-18 — Initial version. TIER_FEATURES map and prices verified against types.ts. checkFeatureAccess return shape noted (no requiredTier field — quilt §1 was slightly off). Four test files in subscription/ confirmed.

On this page