Files
descrybe/apps/api/internal/billing/default_plan_features_seed.go
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

222 lines
6.4 KiB
Go

package billing
import (
"context"
"strings"
)
// EnsureDefaultFeatureSeeds idempotently seeds global section master switches
// (marketing + integrations forced OFF; other sections default ON). Plan feature
// overrides stay sparse: empty '{}' means unset and DefaultPlanFeatures /
// is_custom apply at resolve time.
//
// ASSUMPTION: There is no plans.features_customized flag. A non-empty
// plans.features JSON object means an admin customized the package - this
// seeder never overwrites it (except legacy-flagged plans — see
// EnsureLegacyPlanFeatureSeeds). Empty '{}' means unset.
// Custom / Enterprise (is_custom=true, non-legacy-name) resolve to all features ON.
// A1* with is_custom resolve to A1PaygPlanFeatures (Stores/Marketing/Integrations off).
// Legacy (A1 without is_custom / is_legacy) resolve to the image-nav matrix; empty rows are backfilled.
func (s *Service) EnsureDefaultFeatureSeeds(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
if err := s.seedGlobalSectionGates(ctx); err != nil {
return err
}
return s.EnsureLegacyDefaults(ctx)
}
// EnsureLegacyPlanFeatureSeeds idempotently applies the legacy sparse matrix to
// plans that are legacy by name or is_legacy flag.
//
// Rules:
// - empty features → write SparseLegacyOverrides + mark is_legacy when column exists
// - is_legacy=true → re-apply SparseLegacyOverrides (flagged cohort)
// - non-empty customized (not enable-all) and not flagged → leave alone
func (s *Service) EnsureLegacyPlanFeatureSeeds(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) {
return s.ensureLegacyPlanFeatureSeedsWithoutFlag(ctx)
}
return err
}
defer rows.Close()
type row struct {
id int64
name string
isCustom bool
isLegacy bool
raw []byte
}
var list []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name, &r.isCustom, &r.isLegacy, &r.raw); err != nil {
return err
}
list = append(list, r)
}
if err := rows.Err(); err != nil {
return err
}
sparse := SparseLegacyOverrides()
for _, r := range list {
// Custom / PAYG deals (incl. A1 with is_custom) keep their own matrix —
// never overwrite with legacy-sparse. A1 PAYG hygiene lives in ensureA1PaygPlanSemantics.
if IsCustomPackage(r.name, r.isCustom) {
continue
}
if !IsLegacyPlan(r.name, r.isLegacy) {
continue
}
overrides, derr := decodeFeaturesJSON(r.raw)
if derr != nil {
return derr
}
shouldWrite := r.isLegacy || featuresMapEmpty(overrides)
if !shouldWrite {
continue
}
if _, err := s.SetPlanFeatures(ctx, r.id, sparse); err != nil {
return err
}
if _, err := s.Pool.Exec(ctx, `
UPDATE plans SET is_legacy = true, updated_at = now() WHERE id = $1 AND is_legacy = false`, r.id); err != nil {
if isUndefinedColumn(err) {
continue
}
return err
}
}
return nil
}
func (s *Service) ensureLegacyPlanFeatureSeedsWithoutFlag(ctx context.Context) error {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, is_custom, COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) || isUndefinedRelation(err) {
return nil
}
return err
}
defer rows.Close()
sparse := SparseLegacyOverrides()
for rows.Next() {
var id int64
var name string
var isCustom bool
var raw []byte
if err := rows.Scan(&id, &name, &isCustom, &raw); err != nil {
return err
}
if IsCustomPackage(name, isCustom) {
continue
}
if !IsLegacyPlanName(name) {
continue
}
overrides, derr := decodeFeaturesJSON(raw)
if derr != nil {
return derr
}
if !featuresMapEmpty(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, sparse); err != nil {
return err
}
}
return rows.Err()
}
func (s *Service) seedGlobalSectionGates(ctx context.Context) error {
for _, section := range FeatureSections {
_, err := s.Pool.Exec(ctx, `
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
VALUES ($1, 'section', true, now())
ON CONFLICT (gate_key) DO NOTHING`, section)
if err != nil {
if isUndefinedRelation(err) {
return nil
}
return err
}
}
// Work-mode defaults: keep Marketing + Integrations off platform-wide.
// Upsert so restarts re-assert OFF even if an older seed left them ON.
for _, section := range []string{"marketing", "integrations"} {
_, err := s.Pool.Exec(ctx, `
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
VALUES ($1, 'section', false, now())
ON CONFLICT (gate_key) DO UPDATE
SET enabled = false,
updated_at = now()
WHERE platform_feature_gates.enabled IS DISTINCT FROM false`, section)
if err != nil {
if isUndefinedRelation(err) {
return nil
}
return err
}
}
s.invalidateFeatureGatesCache()
return nil
}
// SparseDefaultOverrides returns only the false keys from DefaultPlanFeatures
// (empty map for custom / all-on packages). Legacy plans return SparseLegacyOverrides.
// Useful for "reset to defaults" admin helpers without storing the full expanded matrix.
func SparseDefaultOverrides(planName string, isCustom bool) map[string]bool {
return SparseDefaultOverridesEx(planName, isCustom, IsLegacyPlanName(planName))
}
// SparseDefaultOverridesEx includes an explicit is_legacy flag.
func SparseDefaultOverridesEx(planName string, isCustom, isLegacy bool) map[string]bool {
if IsCustomPackage(planName, isCustom) {
if IsLegacyPlanName(planName) {
return SparseA1PaygOverrides()
}
return map[string]bool{}
}
if IsLegacyPlan(planName, isLegacy) {
return SparseLegacyOverrides()
}
full := DefaultPlanFeaturesEx(planName, false, false)
out := make(map[string]bool)
for k, v := range full {
if !v {
out[k] = false
}
}
return out
}
// NormalizePublicPlanName maps a plan name to the public ladder key used by
// DefaultPlanFeatures (free|starter|plus|growth|business|scale|enterprise|other).
func NormalizePublicPlanName(planName string) string {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "", "free":
return "free"
case "starter", "plus":
// Plus uses the Starter feature matrix (AI on, BYOK off).
return "starter"
case "growth":
return "growth"
case "business", "scale":
return "business"
case "enterprise":
return "enterprise"
default:
return "other"
}
}