Plans CMS — Epic Plan
Status: planning. Not started. Created 2026-05-20 after S2 (LemonSqueezy adapter) shipped. Owner: TBD Effort estimate: 2-3 days Blocks: Subscription S6 (admin billing UI) depends on plans being available via API; public
/pricingpage also blocks marketing launch.
Why this Epic
S1 + S2 of Subscription Architecture ship the schema, plan catalog, and LemonSqueezy adapter with plans hardcoded as TypeScript constants in core/subscription/infrastructure/plan-catalog/plan-catalog.ts. That worked for the first iteration — schema is right, adapter contracts are right, LS variant IDs flow through env vars.
Two limitations surface when we try to ship the customer-facing pricing page + the super-admin billing screen:
- Plans evolve faster than code. Pricing tweaks, marketing experiments (A/B test "Salon" tier at 449 vs 499 NOK), new add-ons, regional currency overrides — every change today = code change + deploy + re-test. Pricing is product strategy data, not domain logic.
- Admin needs editing power. Owner / super-admin should be able to:
- Add a new plan tier without engineering involvement.
- Toggle features per plan (check/uncheck loyalty/marketplace/etc.) from a UI.
- Mark a plan as "legacy / hidden" so it stops appearing on pricing page but existing tenants keep their grandfathered subscription.
- Re-order plans on the pricing page.
- Update tagline copy + marketing description per plan in both
en+nbwithout a deploy.
This Epic moves plans from constants → DB + ships the super-admin UI + ships the public /pricing page that consumes them.
Scope (in)
Schema
model Plan {
id String @id @default(uuid(7))
/// Platform-stable identifier. Survives provider migrations + survives
/// admin renames of the displayName. Lowercase snake_case (e.g.
/// "salon_monthly"). UNIQUE at the DB level so adapters can rely on
/// it for receipt grouping.
planKey String @unique @map("plan_key")
/// Customer-facing name shown on pricing page + invoices ("Salon").
displayName String @map("display_name")
displayNameNb String @map("display_name_nb")
/// 'monthly' | 'yearly'. Drives `renewal_interval_unit` mapping for
/// the provider; not a free-form string.
billingCycle String @map("billing_cycle")
/// Marketing tagline shown above the price on the pricing card.
tagline String?
taglineNb String? @map("tagline_nb")
/// Long-form description shown on hover / detail panel.
description String? @db.Text
descriptionNb String? @map("description_nb") @db.Text
/// Flat price in minor units (cents / øre). NULL for Enterprise
/// (contact-sales tiers). XOR with pricePerSeatMinor.
flatPriceMinor Int? @map("flat_price_minor")
/// Per-seat price in minor units. NULL for flat plans.
pricePerSeatMinor Int? @map("price_per_seat_minor")
/// Display currency. Stored alongside the price so a regional Plan
/// can override the tenant default.
currency String @db.Char(3) @default("NOK")
/// Whether OWNER can checkout this plan via self-serve. Enterprise =
/// false (contact sales).
selfServeCheckout Boolean @default(true) @map("self_serve_checkout")
/// Public visibility on the pricing page. Legacy plans get
/// isPublic=false so existing subscribers keep grandfathered access
/// but new signups can't pick the plan.
isPublic Boolean @default(true) @map("is_public")
/// Display order on the pricing page (ASC).
sortOrder Int @default(0) @map("sort_order")
/// Trial days override per plan. NULL = use platform default (14).
trialDays Int? @map("trial_days")
/// Provider variant id mapping. Stored on the Plan row itself instead
/// of env vars so super-admin can rotate them without redeploy. NULL
/// when the plan is not yet wired to a provider variant (e.g.
/// Enterprise admin-only) — adapter throws MissingProviderVariantError
/// at checkout time.
lsVariantId String? @map("ls_variant_id")
stripePriceId String? @map("stripe_price_id")
paddlePlanId String? @map("paddle_plan_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by")
planFeatures PlanFeatureOnPlan[]
@@index([isPublic, sortOrder])
@@map("plans")
}
/// Master feature catalog — every flag the platform recognises lives
/// here. Plan ⟷ Plan_Feature is a many-to-many through this junction.
model PlanFeature {
id String @id @default(uuid(7))
/// Stable feature key matching the runtime PlanFeatures interface
/// (e.g. "loyaltyEnabled", "smsCreditsIncluded"). UNIQUE to keep the
/// runtime gate code referencing a single source of truth.
featureKey String @unique @map("feature_key")
/// Human-readable label shown on pricing page + admin UI.
displayName String @map("display_name")
displayNameNb String @map("display_name_nb")
/// Short blurb shown as tooltip / detail.
description String? @db.Text
descriptionNb String? @map("description_nb") @db.Text
/// Category for visual grouping on pricing page.
/// 'bookings' | 'customers' | 'marketing' | 'branding' | 'payments' |
/// 'reports' | 'integrations' | 'support'
category String
/// Mark features that are planned but not yet shipped — pricing page
/// renders them dimmed with a "Coming soon" badge so prospects see
/// the roadmap without thinking they'll get it on day one.
isRoadmap Boolean @default(false) @map("is_roadmap")
/// 'boolean' | 'number' | 'string' — drives admin UI control type
/// (checkbox vs number input vs text). MVP: boolean only; numeric/
/// text added with SMS quota + similar.
valueType String @default("boolean") @map("value_type")
/// Lucide icon key for pricing page (e.g. "sparkles", "shield").
iconKey String? @map("icon_key")
/// Display order within the category on the pricing page.
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
planFeatures PlanFeatureOnPlan[]
@@index([category, sortOrder])
@@map("plan_features")
}
/// Junction — Plan × Feature with the actual value. For boolean
/// features the row's presence = enabled (absence = disabled, no
/// "false" rows needed). For numeric/string features the `value`
/// field carries the actual data.
model PlanFeatureOnPlan {
planId String @map("plan_id")
featureId String @map("feature_id")
/// Stringified value. `null` for boolean features (presence = true).
/// "500" for smsCreditsIncluded etc. Adapter parses at runtime.
value String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
feature PlanFeature @relation(fields: [featureId], references: [id], onDelete: Cascade)
@@id([planId, featureId])
@@map("plan_feature_on_plan")
}
Migration strategy
- Phase M1: ship schema + tables empty. Existing
plan-catalog.tsconstants still serve the runtime. - Phase M2: seed script reads
DEFAULT_CONTENT_PAGES-style constants from currentplan-catalog.ts, writes Plan + PlanFeature + PlanFeatureOnPlan rows ononModuleInit(idempotent, only fills if tables empty). ExistingSubscription.planKeyrows untouched — string key matches the new Plan.planKey column 1:1. - Phase M3: refactor
findPlan/requirePlan/requirePlanFeatureto read from DB instead of constants. Keep the same function signatures so the LS adapter + future Stripe adapter need no changes. - Phase M4: delete the old constants file. Catalog becomes a thin facade over
PlansService.findByKey. - Phase M5: refactor
plan-mapping.config.ts— drop env var lookup, readlsVariantIdfrom Plan row instead. Env varsLEMONSQUEEZY_VARIANT_*deleted from.env.example. Existing dev environments need a one-time data migration to populate the new Plan rows.
Each phase is independently revertable. Phase M2 is the only one that requires data (seed); the rest are code-only swaps.
API
Public (no auth, cache-friendly):
GET /public/plans— returns published plans + features for the pricing page. Response shape includes per-feature value + category + roadmap flag so the FE doesn't need to compute anything.
Admin (OWNER, for in-app upgrade UI):
GET /me/plans— same as public but includes hidden/legacy plans the tenant currently subscribes to (so the billing page can render "your current plan: …" even if legacy).
Super-admin (cross-tenant):
GET /admin/superadmin/plans— list all plans (incl. hidden)POST /admin/superadmin/plans— create new planPATCH /admin/superadmin/plans/:id— update plan (price, copy, visibility, feature toggles)DELETE /admin/superadmin/plans/:id— hard-delete (only when no Subscription row references it; otherwise return 409 with current tenant count and a hint to flipisPublic=falseinstead)GET /admin/superadmin/plan-features— feature catalog (the master list of toggleable flags)POST /admin/superadmin/plan-features— add new feature definition (rare; mostly happens when shipping a new product capability)
Super-admin UI
Path: /admin/superadmin/plans
Two pages:
- Plans list — table with columns: planKey, displayName, billing cycle, price, status (public/hidden/legacy), sort order, feature count, edit/delete actions. Re-order via drag-drop (writes
sortOrderon drop). - Plan edit form — single form with tabs:
- Basics: planKey (read-only after create), displayName + Norwegian, tagline, description, billing cycle, price + currency, trial days, visibility toggles, sort order
- Features: a matrix of all PlanFeature rows grouped by category. Each feature row = label + tooltip + checkbox (boolean) or number input (numeric). Categories: Bookings, Customers, Marketing, Branding, Payments, Reports, Integrations, Support.
- Provider mapping: variant id input per provider (LS / Stripe / Paddle). Each has "test" button that hits the provider's API to verify the variant exists + matches the plan's currency.
Mirror the existing ContentPagesService admin UI for visual consistency.
Public /pricing page
Built as a dedicated Next.js route under (customer)/(info)/pricing, NOT through content_pages (the matrix needs structured data, not HTML). Reads GET /public/plans server-side via getPlatformSettingsServer pattern so the page is SSR-static.
Layout (Norwegian-first):
- Hero: "Velg planen som passer salongen din"
- Plan cards (one per
isPublic=trueplan), sorted bysortOrder, with billing-cycle toggle (Monthly / Yearly) - "Compare features" expandable table showing every PlanFeature grouped by category. Each cell either ✓ (boolean enabled) / numeric value / "—" (not included). Roadmap features rendered dimmed with "Snart" / "Coming soon" badge.
- FAQ section sourced from content_pages slug=pricing-faq (separate seed)
- CTA "Start 14-day trial" + "Talk to sales" (Enterprise)
LS adapter refactor
LemonSqueezyAdapter.createCheckoutSessionreadsplan.lsVariantIdfrom the resolved Plan row instead of callingresolveProviderVariantId(LEMONSQUEEZY, planKey, env).plan-mapping.config.tsdeleted entirely; the reverse helperresolvePlanKeyByVariantIdbecomesPlansService.findByProviderVariantId(provider, variantId)(DB query).- Webhook event-mapper falls back to DB lookup instead of env var lookup when
meta.custom_data.planKeyis absent. assertProviderMappingCompletebecomes a runtime check viaPlansServiceinstead of env scan.
This isolates env from product data. Plans CMS = data; secrets (LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET) stay env.
Scope (out)
- Multi-currency Plan (one Plan, multiple currencies). Defer — currency is a Plan-level field, regional pricing = create regional Plan rows. Future Epic if we expand beyond NOK.
- Coupon / discount engine. LS handles it natively for MVP; tighter integration when we move to Stripe.
- Usage-based add-ons (SMS overage, per-booking commission). V3 — needs Usage Records pipeline.
- Public API access tied to plan (
apiAccessEnabledis just a flag for now; actual API key minting + rate limiting per plan is a separate Epic). - Multi-location plan gating logic (the feature flag exists; the runtime enforcement when a tenant tries to create a second location is a Multi-Location Epic).
Decisions to make (resolve at kickoff)
- Allow super-admin to delete a Plan that has 0 subscribers? Or always require
isPublic=falsefirst to preserve audit trail? Suggested: hard-delete OK at zero-subscriber; force hide-first when ≥1. - Should LS variant ID be required at Plan create, or allowed empty until launch? Suggested: optional at create, validated only when
selfServeCheckout=trueflips on. - Roadmap feature display strategy — dimmed + "Snart" badge, or a separate "Coming next" section below the comparison table? Suggested: inline dimmed (matches Stripe / Linear pricing pages).
- Plan key naming convention — keep current (e.g.
pro_monthly_per_seat) or rebrand to Pattern A (salon_monthly)? Affects existing Subscription rows. Suggested: rebrand via migration that maps old → new keys; brief downtime acceptable since 0 paying customers. - Featured / recommended badge — should there be a "Most popular" highlight on one Plan card? Suggested: yes, gated by a
isFeaturedboolean on Plan; super-admin picks. Add a constraint that at most 1 Plan can be featured per billing cycle.
Phase breakdown
| Phase | Scope | Effort |
|---|---|---|
| PC1 | Schema + migration + seed script (M1 + M2) | 0.5 day |
| PC2 | PlansService (find/list/create/update/delete) + super-admin API |
0.5 day |
| PC3 | Super-admin UI (list + edit form with feature matrix) | 1 day |
| PC4 | Public GET /public/plans API + caching strategy |
0.25 day |
| PC5 | /pricing page component (cards + comparison table + roadmap dimming + i18n) |
1 day |
| PC6 | LS adapter refactor (M5) + delete plan-mapping.config.ts |
0.25 day |
| PC7 | Tests (unit + integration + Playwright pricing page) | 0.5 day |
| PC8 | Docs (architecture update + flow update + features.md + changelog) + memory | 0.25 day |
| Total | ~4.25 days |
Risks
- Constraints between phases: PC2 + PC6 both touch the adapter / mapping layer; ship them in the right order or LS adapter breaks. Sequence: PC1 → PC2 → PC6 → PC4 → PC3 → PC5 → PC7 → PC8.
- Active subscriptions during migration: if any tenant has an active Subscription with an old planKey, the seed must include it. Mitigate with a "verify no orphan planKey" assertion in M2.
- i18n drift: 2 locales × every plan + every feature = many strings. Mitigate with a strict NOT NULL constraint on
displayName+displayNameNbat DB level so a missing translation can't be saved silently. - Caching staleness: public
/pricingSSR cache + admin edits = staleness window. Mitigate with revalidate-on-write (admin save triggers cache purge) instead of TTL.
Liên kết
docs/architecture/subscription-architecture.md— current S1 + S2 implementationdocs/plans/staff-invitation-plan.md— similar admin-tool Epic patternsrc/core/subscription/infrastructure/plan-catalog/plan-catalog.ts— current constants (will move to DB in PC2)src/core/subscription/infrastructure/plan-catalog/plan-mapping.config.ts— current env-var mapping (will be deleted in PC6)