264 lines
13 KiB
Markdown
264 lines
13 KiB
Markdown
# 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` |
|