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.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,263 @@
# 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` |