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

264 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, dont 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` |