Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
13 KiB
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,descriptionmonthly_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 datescustom_monthly_credits,custom_max_products— present in schema, unused by Go billing gatestotal_credits_allocated,contract_reference,notesis_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 | defaultPublicPlans → EnsureDefaultPlans |
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_processingopenai_token_kseo_meta_aicampaign_copy
AI-only vs free-path processing types
ProcessingTypeRequiresAI:enhance,enhance_only,title,description,seo,seo_aiProcessingTypeRequiresEPREL: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}viaIsPublicProductPlan - Exposed to tenants:
GET /api/billing/plans→ListPublicPlans - 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 adminGET /api/admin/plans→ListPlans(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: trueeven 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(nomax_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
UpsertPlansupports update whenid > 0, UI never sendsid) - No delete plan
- No editors for
max_products, description, term, yearly credits - No UI for
company_planscustom 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 / ErrEPRELRequiresUpgrade → 402 + 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 |
7. Recommended extension points (reuse, don’t duplicate)
Prefer extending the existing entitlements + plan catalog path over a parallel permissions subsystem.
-
Catalog shape — Extend
plans(or a JSONfeatures/permissionscolumn) andbilling.Plan+UpsertPlan/EnsureDefaultPlans/ migrator load. Keepis_customas “deal packaging” metadata; use name whitelist or a newis_publicfor self-serve listing ifis_customremains overloaded. -
Capability resolution — Extend
Entitlements+ComputeEntitlements/EntitlementsForCompanyso one function remains the source of truth. Expose viaCreditsOverviewand/api/auth/me(web already readscan_use_*from credits). -
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 +planGateCodemapping inprocessing_handlers.go. -
Optional per-company overrides — Wire
company_plans.custom_*(and any new feature override) intoEntitlementsForCompany/ SKU query instead of inventing a second assignment table. -
Admin UI — Extend
admin/billing/+page.sveltecreate/edit dialog and table (send fullPlanincludingidfor update). Add toggle group UI bound to the same fieldsUpsertPlanpersists. -
Web UX — Gate nav/sections from
me.credits/ entitlements inNav.svelte+ page loads; keep display helpers inbilling-display.ts. -
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 |