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;PlanGaterendert nur nochnull(defensive Content-Schicht). Sichtbarkeit viaisFeatureAvailable+has_*-Flags inlib/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.tsxnotFound()-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.tsPOST prueft jetztisFeatureAvailable(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.tsxnotFound()-Guard bei!has_widget; Tab + Advanced-Kachel ausgeblendet ohne Feature (WidgetForm-internerPlanGaterendert jetztnull) - Backend: 🔧 GEFIXT —
apps/admin/app/api/settings/widget/route.tsPOST prueftisFeatureAvailable(plan, 'has_widget'), antwortet 403 bei Free - Portal: 🔧 GEFIXT —
apps/portal/app/api/portal/branding/route.tsliefertwidget_enabled: boolean+ null-Felder fuer Free-Tenants; eingebetteteswidget.jsfaellt 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.tsPOST liest jetzttenant.planviacustomers.tenant_idund 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.tsxnotFound()-Guard bei!has_bulk_import(PlanGate-Wrap rendert zusätzlichnull) - Backend: 🔧 GEFIXT —
apps/admin/app/api/products/import-export/route.tsblockt sowohlGET(Bulk-Export) als auchPOST(Bulk-Import) bei Free - Status: ✅ vollstaendig
has_api
- Frontend: ✅
apps/admin/app/settings/advanced/api/page.tsx(StubPagelockedFeature: '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.tsxnotFound()-Guard bei!has_printfarm; Sidebar-Eintrag + Advanced-Kachel ausgeblendet ohne Feature. (/settings/advanced/printfarmist seit Single-URL-Rule einpermanentRedirect-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/Advancedhas_crmschloss 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_labelwirkt nur indirekt viashow_powered_by-Inverse - Portal: 🔧 GEFIXT (siehe
show_powered_by) - Hinweis: package-level
has_white_label(auflicense_packages) ist eine separate, paket-spezifische Funktion und wird inapps/portal/app/dashboard/account/PackageUsageSection.tsxkorrekt 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.tsxFooter rendert<div>Powered by licensio.io</div>jetzt nur wennshowPoweredBytrue ist; Wert wird inapps/portal/app/page.tsxausgetPortalPlanFeatures(tenant.plan).show_powered_byberechnet - Status: ✅ vollstaendig
max_products
- Frontend: ❌ kein Counter im Admin (Phase 2 — UI-Hinweis bei Limit-Naehe)
- Backend: 🔧 GEFIXT —
apps/admin/app/api/products/route.tsPOST ruftcheckPlanLimit(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.tsPOST mitcheckPlanLimit(...,'packages') - Status: ✅ vollstaendig
max_customers
- Frontend: ❌ kein Counter im Admin (Phase 2)
- Backend: 🔧 GEFIXT —
apps/admin/app/api/customers/route.tsPOST (Invite-Flow) mitcheckPlanLimit(...,'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 — sieheapps/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:133ruftgetProvisionPercent(tenant.plan)und nutzt den Wert als Stripeapplication_fee_percentbei 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,BooleanFeatureapps/portal/lib/plan-features.ts— minimaler Spiegel der wichtigsten Flags (has_widget,has_feed_export,has_white_label,show_powered_by) fuer Portal-Routes; exportgetPortalPlanFeatures(plan). Wichtig: muss bei Aenderungen inapps/admin/lib/plans.tsmitgepflegt 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):
- Produkt-Limit (Free = 25): 25 Produkte via
/api/productsPOST anlegen, dann ein 26. → erwartet 403 mitupgrade_to: 'basic' - Paket-Limit (Free = 2): 2 Pakete via
/api/payments/packagesPOST anlegen, dann ein 3. → erwartet 403 - Kunden-Limit (Free = 25): 25 Kunden via
/api/customersPOST einladen, dann der 26. → erwartet 403 - Widget-Lock:
POST /api/settings/widgetals Free-Tenant → erwartet 403 - Custom-Domain-Lock:
POST /api/settings/domainals Free-Tenant → erwartet 403 - Bulk-Import-Lock:
GET /api/products/import-exportals Free-Tenant → erwartet 403 - Feed-Export-Lock: Endkunde eines Free-Tenants ruft
POST /api/portal/feed/tokenauf → erwartet 403; bestehender Token bleibt unterGET /api/portal/feed?token=...gueltig - 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
getPortalPlanFeaturesHelper 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)