Zum Hauptinhalt springen

Plan Enforcement Audit

[admin]

Stand: 2026-04-28 — Audit + Fix-Implementation Phase 1.

Dieses Dokument ist die Single-Source-of-Truth fuer die Frage: Welche Features sind plan-abhaengig, und wo werden sie tatsaechlich enforced?

Tier-Struktur (siehe apps/admin/lib/plans.ts): Free / Basic / Pro / Scale.

Methodik

Audit erfolgt auf drei Ebenen:

  • Frontend — Admin-UI blendet Features ohne entsprechendes Tier aus (Nav-Eintrag fehlt) bzw. gibt bei Direktaufruf 404 zurück (notFound()-Guard in der Server-Page). Seit 2026-05-29 kein Lock-Banner / Upgrade-CTA mehr; PlanGate rendert nur noch null (defensive Content-Schicht). Sichtbarkeit via isFeatureAvailable + has_*-Flags in lib/plans.ts.
  • Backend — API-Route muss Plan-Check serverseitig durchfuehren, damit ein Direkt-Call (z. B. via DevTools) das Frontend-Gate nicht umgeht.
  • Portal — Endkunden-Portal (apps/portal) muss Tenant-Plan respektieren, damit White-Label / Powered-by / Feed-Export korrekt sichtbar/verwehrt sind.

= enforced, = Luecke, 🟡 = teilweise (z. B. nur Frontend), 🟢 = OK, 🔴 = kritische Luecke, 🔧 = im aktuellen Commit gefixt, N/A = nicht zutreffend.

Feature-Matrix

has_custom_domain

  • Frontend: ✅ apps/admin/app/settings/(tabbed)/domain/page.tsx notFound()-Guard bei !has_custom_domain (2026-05-29 — vorher war DomainForm ungated, Page-Gate-Lücke geschlossen); Tab + Advanced-Kachel ausgeblendet ohne Feature
  • Backend: 🔧 GEFIXT — apps/admin/app/api/settings/domain/route.ts POST prueft jetzt isFeatureAvailable(plan, 'has_custom_domain'), antwortet 403 bei Free
  • Portal: N/A (Portal liest tenant.custom_domain-Spalte direkt; ohne Wert kein Effekt)
  • Status: ✅ vollstaendig

has_widget

  • Frontend: ✅ apps/admin/app/settings/(tabbed)/widget/page.tsx notFound()-Guard bei !has_widget; Tab + Advanced-Kachel ausgeblendet ohne Feature (WidgetForm-interner PlanGate rendert jetzt null)
  • Backend: 🔧 GEFIXT — apps/admin/app/api/settings/widget/route.ts POST prueft isFeatureAvailable(plan, 'has_widget'), antwortet 403 bei Free
  • Portal: 🔧 GEFIXT — apps/portal/app/api/portal/branding/route.ts liefert widget_enabled: boolean + null-Felder fuer Free-Tenants; eingebettetes widget.js faellt auf Default-Branding zurueck statt Tenant-Farben zu uebernehmen
  • Status: ✅ vollstaendig

has_feed_export

  • Frontend: ❌ N/A — Feed-Export-UI lebt im Portal (Endkunden-Self-Service); Admin sieht nichts
  • Backend: 🔧 GEFIXT — apps/portal/app/api/portal/feed/token/route.ts POST liest jetzt tenant.plan via customers.tenant_id und blockt Token-Generierung bei !has_feed_export
  • Portal-GET (/api/portal/feed): 🟢 BEWUSST OFFEN — bestehende Tokens (vor Plan-Downgrade ausgegeben) bleiben gueltig, nur Neugenerierung ist gesperrt. Tenant kann Token via Admin/UI manuell revoken (UI-Feature TBD)
  • Status: ✅ vollstaendig (Token-Create gesperrt; bestehende Tokens bleiben gueltig per User-Spec)

has_bulk_import

  • Frontend: ✅ apps/admin/app/products/import/page.tsx notFound()-Guard bei !has_bulk_import (PlanGate-Wrap rendert zusätzlich null)
  • Backend: 🔧 GEFIXT — apps/admin/app/api/products/import-export/route.ts blockt sowohl GET (Bulk-Export) als auch POST (Bulk-Import) bei Free
  • Status: ✅ vollstaendig

has_api

  • Frontend: ✅ apps/admin/app/settings/advanced/api/page.tsx (StubPage lockedFeature: 'has_api'notFound() bei !has_api); Advanced-Kachel ausgeblendet ohne Feature
  • Backend: 🟢 N/A — API-Keys-Endpoint ist Phase 2 / noch nicht gebaut. Sobald er existiert, MUSS er isFeatureAvailable(plan, 'has_api') aufrufen
  • Status: ✅ vollstaendig fuer aktuelle Phase

has_printfarm (pro + scale, seit 2026-05-29)

  • Frontend: ✅ apps/admin/app/printfarm/page.tsx notFound()-Guard bei !has_printfarm; Sidebar-Eintrag + Advanced-Kachel ausgeblendet ohne Feature. (/settings/advanced/printfarm ist seit Single-URL-Rule ein permanentRedirect-Stub auf /printfarm.)
  • Backend: 🟢 N/A — Drucker-Agent / Druckauftrags-API ist Phase 2 / noch nicht gebaut. Sobald sie existiert, MUSS sie gegated sein
  • Status: ✅ vollstaendig fuer aktuelle Phase. Korrigiert die frühere 3-Variant-Inkonsistenz (Sidebar plan==='pro' schloss Scale aus, Page/Advanced has_crm schloss Pro aus)

has_crm

  • Frontend: 🟢 N/A — kein eigenes CRM-UI mehr gegated; PrintFarm nutzt jetzt has_printfarm. CRM-Anbindung selbst ist Phase 2
  • Backend: 🟢 N/A — CRM-Anbindung ist Phase 2 / noch nicht gebaut. Sobald sie existiert, MUSS sie gegated sein (has_crm, scale-only)
  • Status: ✅ N/A fuer aktuelle Phase

has_white_label

  • Frontend: ❌ kein Toggle im Admin (Tenant-Plan-Level kann nicht direkt konfiguriert werden — folgt automatisch aus dem Plan)
  • Backend: 🟢 N/A — Branding ist immer setzbar; has_white_label wirkt nur indirekt via show_powered_by-Inverse
  • Portal: 🔧 GEFIXT (siehe show_powered_by)
  • Hinweis: package-level has_white_label (auf license_packages) ist eine separate, paket-spezifische Funktion und wird in apps/portal/app/dashboard/account/PackageUsageSection.tsx korrekt angezeigt — nicht vom Tenant-Plan-Tier abhaengig
  • Status: ✅ vollstaendig (per inverse von show_powered_by)

has_sso

  • Frontend: ❌ N/A — Feature noch nicht gebaut
  • Backend: ❌ N/A — Feature noch nicht gebaut
  • Status: 🟢 fuer aktuelle Phase OK; bei Implementierung muss SSO-Login-Endpoint gegated sein

has_landing_page

  • Plans-Limits: alle 4 Plans haben has_landing_page: true → kein Gate noetig
  • Status: 🟢 universal feature — kein Enforcement noetig

show_powered_by (Inverse von has_white_label)

  • Frontend: ❌ N/A — kein Admin-Toggle; folgt automatisch aus Plan
  • Portal: 🔧 GEFIXT — apps/portal/app/components/LandingPage.tsx Footer rendert <div>Powered by licensio.io</div> jetzt nur wenn showPoweredBy true ist; Wert wird in apps/portal/app/page.tsx aus getPortalPlanFeatures(tenant.plan).show_powered_by berechnet
  • Status: ✅ vollstaendig

max_products

  • Frontend: ❌ kein Counter im Admin (Phase 2 — UI-Hinweis bei Limit-Naehe)
  • Backend: 🔧 GEFIXT — apps/admin/app/api/products/route.ts POST ruft checkPlanLimit(supabase, tenant.id, plan, 'products') vor Insert; antwortet 403 mit { upgrade_required, limit, current, upgrade_to } bei Erreichen
  • Status: ✅ vollstaendig

max_packages

  • Frontend: ❌ kein Counter im Admin (Phase 2)
  • Backend: 🔧 GEFIXT — apps/admin/app/api/payments/packages/route.ts POST mit checkPlanLimit(...,'packages')
  • Status: ✅ vollstaendig

max_customers

  • Frontend: ❌ kein Counter im Admin (Phase 2)
  • Backend: 🔧 GEFIXT — apps/admin/app/api/customers/route.ts POST (Invite-Flow) mit checkPlanLimit(...,'customers')
  • Hinweis: Activate-Route (apps/admin/app/api/customers/[id]/activate/route.ts) ist NICHT gegated — pro User-Spec zaehlt Reaktivierung NICHT als neuer Verbrauch (Customer-Record existiert bereits)
  • Status: ✅ vollstaendig

max_printers, max_downloads

  • Plans-Limits: alle 4 Plans haben null (unbegrenzt) auf Tenant-Plan-Level → kein Gate noetig
  • Hinweis: per-Paket Limits (license_packages.max_printers, max_downloads_per_month) werden separat enforced — siehe apps/portal/app/api/portal/printers/route.ts
  • Status: 🟢 OK (kein Tenant-Plan-Gate definiert)

provision_percent

  • Backend: ✅ apps/admin/app/api/customers/[id]/activate/route.ts:133 ruft getProvisionPercent(tenant.plan) und nutzt den Wert als Stripe application_fee_percent bei Subscription-Erstellung. Free=15%, Basic=5%, Pro=2%, Scale=1%
  • Status: ✅ vollstaendig

Hilfs-Module

  • apps/admin/lib/plans.ts — Single-Source-of-Truth fuer Plan-Limits, Feature-Flags, Pricing. Exports: PLAN_LIMITS, PLAN_LABELS, PLAN_PRICES_EUR, PLAN_DESCRIPTIONS, getPlanLimits, isFeatureAvailable, getUpgradePlan, getProvisionPercent, checkPlanLimit, planLimitErrorPayload, BooleanFeature
  • apps/portal/lib/plan-features.ts — minimaler Spiegel der wichtigsten Flags (has_widget, has_feed_export, has_white_label, show_powered_by) fuer Portal-Routes; export getPortalPlanFeatures(plan). Wichtig: muss bei Aenderungen in apps/admin/lib/plans.ts mitgepflegt werden — Cross-Repo-Schmerz, der sich erst bei einer geteilten lib-Sammlung (packages/plans/) loesen laesst (Phase 2)

Antwort-Schemas fuer Plan-Block-403s

Backend Hard Limits (max_products / max_packages / max_customers):

{
"error": "Dein Plan erlaubt maximal 25 Produkte.",
"upgrade_required": true,
"resource": "products",
"limit": 25,
"current": 25,
"upgrade_to": "basic"
}

Feature-Flag Locks (has_widget / has_custom_domain / has_bulk_import / has_feed_export):

{
"error": "Widget ist nicht in deinem Plan enthalten.",
"upgrade_required": true,
"upgrade_to": "basic"
}

Das upgrade_required: true-Flag erlaubt dem Admin-Frontend, automatisch ein Upgrade-Modal zu zeigen (UI-Implementation TBD, Phase 2).

Test-Strategie

Plan-Enforcement-Smoke-Tests sind manuell durchzufuehren (Test-Tenant testshop ist auf Free):

  1. Produkt-Limit (Free = 25): 25 Produkte via /api/products POST anlegen, dann ein 26. → erwartet 403 mit upgrade_to: 'basic'
  2. Paket-Limit (Free = 2): 2 Pakete via /api/payments/packages POST anlegen, dann ein 3. → erwartet 403
  3. Kunden-Limit (Free = 25): 25 Kunden via /api/customers POST einladen, dann der 26. → erwartet 403
  4. Widget-Lock: POST /api/settings/widget als Free-Tenant → erwartet 403
  5. Custom-Domain-Lock: POST /api/settings/domain als Free-Tenant → erwartet 403
  6. Bulk-Import-Lock: GET /api/products/import-export als Free-Tenant → erwartet 403
  7. Feed-Export-Lock: Endkunde eines Free-Tenants ruft POST /api/portal/feed/token auf → erwartet 403; bestehender Token bleibt unter GET /api/portal/feed?token=... gueltig
  8. Powered-by: Tenant auf Free → https://<slug>.licensio.io/ zeigt "Powered by licensio.io" im Footer; Tenant auf Basic+ → kein Badge

Zusammenfassung

Vor diesem Audit (2026-04-28 Vormittag):

  • 5 kritische Luecken (max_products, max_packages, max_customers, has_feed_export, show_powered_by)
  • 3 Backend-Bypass-Luecken (has_widget, has_custom_domain, has_bulk_import)

Nach Phase 1 (dieser Commit):

  • ✅ Alle 5 kritischen Luecken geschlossen
  • ✅ Alle 3 Backend-Bypass-Luecken geschlossen
  • ✅ Portal-side getPortalPlanFeatures Helper etabliert
  • ✅ Standardisierte 403-Response-Schemas mit upgrade_required-Flag

Phase 2 / offene Punkte (kein Blocker):

  • UI-Counter im Admin (z. B. "23/25 Produkte") + automatisches Upgrade-Modal bei 403-Response
  • API-Keys + CRM Endpoints (sobald gebaut → mit Plan-Gate)
  • SSO-Login-Endpoint mit has_sso-Gate
  • Geteilte packages/plans/ Lib statt Portal-Spiegel-Datei
  • Admin-UI um bestehende Feed-Tokens manuell zu revoken (z. B. nach Plan-Downgrade)