Files
descrybe/docs/plan-permissions/02-plans-permissions-current.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

13 KiB
Raw Blame History

02 — Plans / packages / permissions (current system)

Inventory of how plans (“packages”) work today in descrybe-v2.
Sibling folder docs/plan-permissions/ was empty at write time (no 01-* / 03-* artifacts yet). Aligns with existing product docs: docs/free-tier.md, docs/billing-credits-audit.md.

ASSUMPTION: “packages” in product language maps 1:1 to DB table plans + assignment via company_plans. There is no separate packages table.


1. Models, tables, DTOs, seeders

Tables (apps/api/sql/schema/001_platform.sql, Stripe extras in 016_stripe_billing.sql)

Table Role
plans Catalog of packages: meters + flags
company_plans Per-company assignment (active row, trial, Stripe ids, unused override columns)
credit_balances Wallet: total_credits, used_credits
billing_cycles Cycle usage counters / invoicing
processing_costs Per-feature credit unit costs (product_processing, openai_token_k, seo_meta_ai, campaign_copy)
companies.stripe_customer_id Stripe customer link
company_plans.stripe_subscription_id / stripe_price_id Subscription linkage

plans columns

  • id, name, description
  • monthly_credits, yearly_credits (nullable)
  • max_products (nullable = unlimited SKU cap)
  • is_custom (boolean, default false)
  • term (default 'monthly')
  • timestamps

company_plans columns (assignment + overrides)

  • plan_id, is_active, billing/contract dates
  • custom_monthly_credits, custom_max_productspresent in schema, unused by Go billing gates
  • total_credits_allocated, contract_reference, notes
  • is_trial, trial_ends_at, trial_credits
  • Stripe subscription/price ids (migration 016)

Go DTOs / types (apps/api/internal/billing/)

Symbol File Purpose
Plan service.go JSON DTO for catalog CRUD/list
CreditsOverview service.go Wallet + plan snapshot + entitlement booleans for /api/billing/credits and /api/auth/me
Entitlements entitlements.go Pure capability snapshot (can_use_ai, can_use_eprel, free/paid/trial)
ProcessingGateOpts service.go {RequiresAI, RequiresEPREL} for start-job gate
Web Plan / credits types apps/web/src/lib/types.ts, billing-display.ts, admin page local types Mirror API shapes

Seed / bootstrap (not SQL seeders)

Mechanism Symbol / path What it does
Public ladder upsert defaultPublicPlansEnsureDefaultPlans Free / Starter / Growth / Business / Enterprise by name
Signup Free ProvisionFreePlan Assign Free if present
Credit cost rows EnsureDefaultCosts Idempotent processing_costs insert
Demo seed apps/api/cmd/seed-demo EnsureDefaultPlans then assign Enterprise to Local Demo Co; asserts Free stays at 0 credits
Migrator ETL apps/api/cmd/migrator (loadPlans, loadCompanyPlans, company_plans_repair.go) MySQL → Postgres plans + repair missing assignments
On-demand Admin/public list handlers call EnsureDefaultPlans before list

Default public packaging (defaultPublicPlans):

Name Monthly credits Max products is_custom
Free 0 100 false
Starter 300 1_000 false
Growth 2_000 10_000 false
Business 10_000 100_000 false
Enterprise 1_000_000 (EnterpriseUnlimitedCredits) null (unlimited) true

2. Existing feature / limit / permission fields

There is no plan-scoped permission matrix (no per-tab / per-section toggles, no plan_permissions table, no feature-flag JSON on plans).

Capabilities today are derived or metered:

Entitlement booleans (ComputeEntitlements / CreditsOverview)

Field Meaning (current code)
is_free_plan / is_paid_plan Name equals "free" (or empty) vs not
is_trial From active company_plans.is_trial
monthly_credits From plans.monthly_credits
remaining_credits max(0, total - used) wallet
can_use_ai remaining > 0 OR paid plan (not Free). Paid with empty wallet still has can_use_ai=true, but AI jobs require remaining ≥ batch size
can_use_eprel Always true in code (EU public data). Platform can still disable enricher via settings/env. Note: docs/free-tier.md still describes paid-only EPREL — docs lag code

Hard limits

Limit Source Enforced by
SKU / product cap plans.max_products (null/≤0 = unlimited) AssertCanStartProcessing vs count(processed_products)
AI credit wallet credit_balances Gate on AI job types + ConsumeCredits / batch
Brand voice in AI Paid or trial AIBrandApplyAllowed

Credit feature keys (processing_costs.feature_name)

Metering only (cost per unit), not allow/deny:

  • product_processing
  • openai_token_k
  • seo_meta_ai
  • campaign_copy

AI-only vs free-path processing types

  • ProcessingTypeRequiresAI: enhance, enhance_only, title, description, seo, seo_ai
  • ProcessingTypeRequiresEPREL: eprel, eprel_only
  • Non-AI types (normalize/specs/fill/full with step policy) can run on Free with 0 credits; pipeline may skip AI/EPREL steps

Membership / platform roles (orthogonal to plans)

Company admin / member roles and users.is_platform_admin gate who can call admin APIs — not which product tabs a plan unlocks.


3. Default vs custom packages

Two different notions exist; do not conflate them.

A. Public product ladder vs client deals (listing / checkout)

  • Public: name ∈ {Free, Starter, Growth, Business, Enterprise} via IsPublicProductPlan
  • Exposed to tenants: GET /api/billing/plansListPublicPlans
  • Stripe self-serve checkout also keys off public names
  • Client / custom deals (A1, Merkur trial, legacy names, admin-created packages): remain in plans, visible only via admin GET /api/admin/plansListPlans (all rows)

EnsureDefaultPlans syncs only names in defaultPublicPlans; named client deals are left untouched.

B. plans.is_custom flag

  • Boolean on the row; admin UI Badge “Custom” vs “Standard”
  • Admin create dialog defaults is_custom: true
  • Public Enterprise is seeded with is_custom: true even though it is a public ladder plan
  • Not used by ListPublicPlans (name whitelist wins)
  • Not used by entitlement/gates

Per-company overrides

Schema columns company_plans.custom_monthly_credits / custom_max_products exist for deal overrides, but billing Go code does not read them today. Assignment always copies plans.monthly_credits (or trial credits) into the wallet; SKU gate reads plans.max_products only.


4. Admin UI for editing packages

Path Role
apps/web/src/routes/admin/billing/+page.svelte Platform billing admin page
apps/web/src/lib/components/AdminNav.svelte Nav link “Platform billing” → /admin/billing
Gate requirePlatformAdmin ($lib/admin-gate)

Capabilities on the page

  • List all plans (GET /api/admin/plans)
  • Create plan dialog (POST /api/admin/plans) — body: name, monthly_credits, is_custom (no max_products / description / term in UI)
  • Assign plan to company (POST /api/admin/plans/assign)
  • Add credits (POST /api/admin/credits)
  • Run due billing cycles (POST /api/admin/billing/run-cycles)
  • Companies tab shows wallet totals from GET /api/admin/companies

Gaps in admin UI

  • No in-place edit of existing plan meters (API UpsertPlan supports update when id > 0, UI never sends id)
  • No delete plan
  • No editors for max_products, description, term, yearly credits
  • No UI for company_plans custom override columns or permission toggles

Related tenant-facing (not admin edit): /plans, /billing, /pricing + billing-display.ts.


5. API endpoints that enforce plan limits

Catalog / admin (mutate packages & assignment)

Mounted under /api/admin with RequireSession + RequirePlatformAdmin (server.go):

Method Path Handler
GET /api/admin/plans handleListPlans
POST /api/admin/plans handleUpsertPlan
POST /api/admin/plans/assign handleAssignPlan
POST /api/admin/credits handleAddCredits
POST /api/admin/billing/run-cycles handleRunBillingCycles

Tenant billing read / Stripe

Under /api + session + company:

Method Path Notes
GET /api/billing/credits CreditsOverview (includes entitlement flags + SKU usage)
GET /api/billing/usage Usage summary
GET /api/billing/plans Public ladder only
POST /api/billing/checkout / portal Stripe; public plans
GET /api/auth/me Embeds credits overview

Enforcement call sites (402 Payment Required pattern)

Central gate: Service.AssertCanStartProcessing → called from processing.Pipeline.StartJob.

Surface How limits apply
POST processing jobs (processing_handlers, v1_process_handlers) StartJob → AssertCanStartProcessing; maps ErrInsufficientCredits / ErrProductLimitExceeded / ErrAIRequiresUpgrade / ErrEPRELRequiresUpgrade402 + upgrade_url
Pipeline processOne Step policy: AI only if CanUseAI && RemainingCredits > 0; ConsumeCredits
SEO AI apply (seo/service.go + handlers) Entitlements + ConsumeCredits("seo_meta_ai")
Campaign AI generate (campaigns/generate_send.go) Entitlements; Free blocked; ConsumeCredits("campaign_copy")
Brand kit (brand_handlers.go) ai_apply_allowed via AIBrandApplyAllowed (edit kit allowed on Free; AI inject gated)
Feed sample sync+process Shares StartJob path (handleSyncAndProcessSample)

Wallet debit: ConsumeCredits / ConsumeCreditsBatch (atomic remaining check).


6. Gaps vs per-tab / section permission toggles

Need Current state
Toggle “Feeds / Products / SEO / Campaigns / Exports / …” per plan Missing — nav is role-based, not plan-based
Boolean feature flags on plans Missing — only meters + is_custom + name heuristics
Per-company override of feature set Schema has unused credit/SKU override columns; no feature override
Distinguish marketing “custom package” vs Enterprise is_custom Confusing: Enterprise is public and is_custom
Admin edit of full plan surface Create-only UI; no permission matrix editor
Server enforcement for “section disabled” Only AI/SKU/credit gates — disabled sections would still be reachable unless added
Docs drift free-tier.md EPREL paid-only vs code CanUseEPREL=true

Prefer extending the existing entitlements + plan catalog path over a parallel permissions subsystem.

  1. Catalog shape — Extend plans (or a JSON features / permissions column) and billing.Plan + UpsertPlan / EnsureDefaultPlans / migrator load. Keep is_custom as “deal packaging” metadata; use name whitelist or a new is_public for self-serve listing if is_custom remains overloaded.

  2. Capability resolution — Extend Entitlements + ComputeEntitlements / EntitlementsForCompany so one function remains the source of truth. Expose via CreditsOverview and /api/auth/me (web already reads can_use_* from credits).

  3. Server enforcement — Add small helpers next to AssertCanStartProcessing / AIBrandApplyAllowed (e.g. AssertFeature(ctx, companyID, "seo")) and call from handlers that own each surface (processing, seo, campaigns, feeds, exports). Reuse 402 + planGateCode mapping in processing_handlers.go.

  4. Optional per-company overrides — Wire company_plans.custom_* (and any new feature override) into EntitlementsForCompany / SKU query instead of inventing a second assignment table.

  5. Admin UI — Extend admin/billing/+page.svelte create/edit dialog and table (send full Plan including id for update). Add toggle group UI bound to the same fields UpsertPlan persists.

  6. Web UX — Gate nav/sections from me.credits / entitlements in Nav.svelte + page loads; keep display helpers in billing-display.ts.

  7. Do not invent a second “packages” model, duplicate Free/paid heuristics outside IsFreePlanName / ComputeEntitlements, or add ad-hoc flags only in the frontend.


Quick reference — key files

Area Path
Schema apps/api/sql/schema/001_platform.sql, 016_stripe_billing.sql
Service / Plan / gates apps/api/internal/billing/service.go
Entitlements apps/api/internal/billing/entitlements.go
HTTP billing/admin apps/api/internal/httpapi/billing_handlers.go, server.go
Processing gate apps/api/internal/processing/pipeline.go
Admin UI apps/web/src/routes/admin/billing/+page.svelte
Display helpers apps/web/src/lib/billing-display.ts
Seed apps/api/cmd/seed-demo/main.go