# 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_products` — **present 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 | `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_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/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 **admin** `GET /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: 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` / `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. 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` |