100 lines
3.7 KiB
Markdown
100 lines
3.7 KiB
Markdown
# 04 — Backend model (agent 4/10)
|
|||
|
|
|
||
|
|
Implements storage + billing service APIs for plan dashboard permissions and global section/feature master switches, aligned with `03-permission-contract.md`.
|
||
|
|
|
||
|
|
**BREAKING:** none (additive schema + fields + methods only).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Schema (`apps/api/sql/schema/026_plan_features.sql`)
|
||
|
|
|
||
|
|
| Artifact | Purpose |
|
||
|
|
|----------|---------|
|
||
|
|
| `plans.features` | `JSONB NOT NULL DEFAULT '{}'` — sparse `feature_key → bool` overrides |
|
||
|
|
| `platform_feature_gates` | Global masters: `gate_key`, `kind` (`section`\|`feature`), `enabled`, `updated_at`, `updated_by` |
|
||
|
|
|
||
|
|
Missing gate rows ⇒ enabled (migration-safe). Goose Up/Down included.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Go package (`apps/api/internal/billing/`)
|
||
|
|
|
||
|
|
| File | Role |
|
||
|
|
|------|------|
|
||
|
|
| `feature_catalog.go` | Registry keys, sections, Free/Starter deny lists |
|
||
|
|
| `plan_features.go` | Resolve + admin CRUD (`Get/SetFeatureGates`, `Get/SetPlanFeatures`, `CapabilitiesForCompany`, …) |
|
||
|
|
| `features_api.go` | Task-named surface: `ListFeatures`, `IsAllowed`, `SetPlanFeature`, `SetGlobalFeature`, `EnableAllForPlan`, `ApplyDefaultMatrix`, `AssertFeature`, `ResolveFeatures` |
|
||
|
|
| `default_plan_features_seed.go` | `EnsureDefaultFeatureSeeds` (idempotent section rows; never clobber non-empty plan overrides) |
|
||
|
|
| `service.go` | `Plan.Features` / `ResolvedFeatures`; `ListPlans` / `UpsertPlan` read/write JSON; `CreditsOverview` embeds effective features |
|
||
|
|
| `client_errors.go` | Maps feature gate errors for HTTP clients |
|
||
|
|
| `features_api_test.go` | Pure resolve/default/validate tests |
|
||
|
|
|
||
|
|
`EnsureDefaultPlans` still updates meters only — **does not** overwrite `features`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Resolution (normative)
|
||
|
|
|
||
|
|
```
|
||
|
|
effective(key) = plan_allows(key)
|
||
|
|
AND global_section_enabled(section(key))
|
||
|
|
AND global_feature_enabled(key)
|
||
|
|
```
|
||
|
|
|
||
|
|
- `plan_allows`: override in `plans.features` if present; else `is_custom` ⇒ true; else `DefaultPlanFeatures(name, false)`.
|
||
|
|
- Globals: missing row ⇒ true.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## How to use the service
|
||
|
|
|
||
|
|
```go
|
||
|
|
svc := &billing.Service{Pool: pool}
|
||
|
|
|
||
|
|
// Catalog
|
||
|
|
defs, _ := svc.ListFeatures(ctx)
|
||
|
|
|
||
|
|
// Effective check (company's active plan ∧ globals)
|
||
|
|
ok, err := svc.IsAllowed(ctx, companyID, "marketing.campaigns.generate_ai")
|
||
|
|
if err := svc.AssertFeature(ctx, companyID, "marketing.campaigns.generate_ai"); err != nil {
|
||
|
|
// errors.Is(err, billing.ErrFeatureDisabled) → HTTP 402 plan_gate
|
||
|
|
}
|
||
|
|
|
||
|
|
// Admin: per-plan override merge / replace / all-on / reset defaults
|
||
|
|
_ = svc.SetPlanFeature(ctx, planID, "settings.api_keys", true)
|
||
|
|
_, _ = svc.SetPlanFeatures(ctx, planID, map[string]bool{"settings.api_keys": true})
|
||
|
|
_ = svc.EnableAllForPlan(ctx, planID)
|
||
|
|
_ = svc.ApplyDefaultMatrix(ctx, planID) // sparse Free/Starter offs; custom → {}
|
||
|
|
|
||
|
|
// Admin: global masters (section or feature key)
|
||
|
|
_ = svc.SetGlobalFeature(ctx, "marketing", false, &adminUserID)
|
||
|
|
_ = svc.SetGlobalFeature(ctx, "capability.byok", false, &adminUserID)
|
||
|
|
gates, _ := svc.GetFeatureGates(ctx)
|
||
|
|
|
||
|
|
// Tenant payload (also on CreditsOverview /auth/me)
|
||
|
|
caps, _ := svc.CapabilitiesForCompany(ctx, companyID)
|
||
|
|
// caps.Features, caps.DisabledFeatures, caps.FeatureETag, caps.Entitlements
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pure helpers (no DB)
|
||
|
|
|
||
|
|
- `DefaultPlanFeatures(name, isCustom)`
|
||
|
|
- `PlanAllowsFeature(name, isCustom, overrides, key)`
|
||
|
|
- `ResolveFeatures` / `ResolveEffectiveFeatures`
|
||
|
|
- `SparseDefaultOverrides(name, isCustom)`
|
||
|
|
- `IsKnownFeatureKey` / `IsKnownFeatureSection` / `SectionOfFeature`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Seed / bootstrap
|
||
|
|
|
||
|
|
Call `EnsureDefaultFeatureSeeds` after migrations (alongside `EnsureDefaultPlans` if desired). Seeds section gates ON; leaves empty `{}` plan overrides so runtime defaults apply.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Out of scope (other agents)
|
||
|
|
|
||
|
|
- HTTP routes under `/api/admin/feature-gates` and capabilities endpoints
|
||
|
|
- Frontend / admin UI toggles
|
||
|
|
- Wiring `AssertFeature` into every handler (processing, SEO, campaigns, …)
|