package billing import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) var ( ErrUnknownFeatureKey = errors.New("unknown feature key") ErrUnknownFeatureSection = errors.New("unknown feature section") ErrInvalidFeatureGates = errors.New("invalid feature gates payload") ErrFeatureDisabled = errors.New("feature_disabled") ) // FeatureGatesView is the admin global master-switch snapshot. type FeatureGatesView struct { Sections map[string]bool `json:"sections"` Features map[string]bool `json:"features"` } // PlanFeaturesView is the admin per-plan feature editor payload. type PlanFeaturesView struct { PlanID int64 `json:"plan_id"` PlanName string `json:"plan_name"` IsCustom bool `json:"is_custom"` IsLegacy bool `json:"is_legacy"` Features map[string]bool `json:"features"` ResolvedFeatures map[string]bool `json:"resolved_features"` } // Capabilities is the tenant-resolved plan ∩ global feature matrix. type Capabilities struct { PlanID int64 `json:"plan_id,omitempty"` PlanName string `json:"plan_name"` IsCustom bool `json:"is_custom"` IsLegacy bool `json:"is_legacy"` HasActivePlan bool `json:"has_active_plan"` Features map[string]bool `json:"features"` Sections map[string]bool `json:"sections"` DisabledFeatures []string `json:"disabled_features"` FeatureETag string `json:"feature_etag"` Entitlements Entitlements `json:"entitlements"` } // FeatureGatesUpdate is the PUT /api/admin/feature-gates body. type FeatureGatesUpdate struct { Sections map[string]bool `json:"sections"` Features map[string]bool `json:"features"` } // PlanFeaturesUpdate is the PUT /api/admin/plans/{id}/features body. // Features replaces the stored overrides object (sparse map). type PlanFeaturesUpdate struct { Features map[string]bool `json:"features"` } // SectionGateUpdate is the PUT /api/admin/feature-gates/sections/{section} body. // Enabled is required (*bool) so omitting the field cannot silently disable a section. type SectionGateUpdate struct { Enabled *bool `json:"enabled"` } // DefaultPlanFeatures returns the expanded default matrix for a plan name. // Legacy (A1 / is_legacy patterns) uses the image-nav allow-list — not custom all-ON. // Custom packages (isCustom, non-legacy) default all registry keys ON. func DefaultPlanFeatures(planName string, isCustom bool) map[string]bool { return DefaultPlanFeaturesEx(planName, isCustom, IsLegacyPlanName(planName)) } // DefaultPlanFeaturesEx is DefaultPlanFeatures with an explicit is_legacy flag. func DefaultPlanFeaturesEx(planName string, isCustom, isLegacy bool) map[string]bool { out := make(map[string]bool, len(FeatureCatalogKeys)) // Custom deals get enable-all, except A1* PAYG which keeps Stores + AI off. if IsCustomPackage(planName, isCustom) { if IsLegacyPlanName(planName) { return A1PaygPlanFeatures() } for _, k := range FeatureCatalogKeys { out[k] = true } return out } if IsLegacyPlan(planName, isLegacy) { for _, k := range FeatureCatalogKeys { out[k] = LegacyFeatureAllowed(k) } return out } norm := strings.ToLower(strings.TrimSpace(planName)) for _, k := range FeatureCatalogKeys { allowed := true switch norm { case "", "free": allowed = !freePlanFeatureOff(k) case "starter", "plus": allowed = !starterPlanFeatureOff(k) default: // Growth / Business / Scale / named public ladder: all ON except unknown. allowed = true } out[k] = allowed } return out } // PlanAllowsFeature resolves plan_allows(key) without global gates. func PlanAllowsFeature(planName string, isCustom bool, overrides map[string]bool, key string) bool { return PlanAllowsFeatureEx(planName, isCustom, IsLegacyPlanName(planName), overrides, key) } // PlanAllowsFeatureEx is PlanAllowsFeature with an explicit is_legacy flag. func PlanAllowsFeatureEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, key string) bool { // A1 PAYG deny list always wins — stale stored matrices must not re-enable // Stores / Marketing / Integrations after seed hygiene expands the deny set. if IsCustomPackage(planName, isCustom) && IsLegacyPlanName(planName) && A1PaygFeatureDenied(key) { return false } if overrides != nil { if v, ok := overrides[key]; ok { return v } } if IsCustomPackage(planName, isCustom) { if IsLegacyPlanName(planName) { return !A1PaygFeatureDenied(key) } return true } if IsLegacyPlan(planName, isLegacy) { defaults := DefaultPlanFeaturesEx(planName, isCustom, true) if v, ok := defaults[key]; ok { return v } return false } defaults := DefaultPlanFeaturesEx(planName, false, false) if v, ok := defaults[key]; ok { return v } return false } // ResolveEffectiveFeatures applies plan ∩ global section ∩ global feature. func ResolveEffectiveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) { return ResolveEffectiveFeaturesEx(planName, isCustom, IsLegacyPlanName(planName), overrides, gates) } // ResolveEffectiveFeaturesEx is ResolveEffectiveFeatures with an explicit is_legacy flag. func ResolveEffectiveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) { sections = make(map[string]bool, len(FeatureSections)) for _, s := range FeatureSections { enabled := true if gates.Sections != nil { if v, ok := gates.Sections[s]; ok { enabled = v } } sections[s] = enabled } features = make(map[string]bool, len(FeatureCatalogKeys)) disabled = make([]string, 0) for _, key := range FeatureCatalogKeys { allowed := PlanAllowsFeatureEx(planName, isCustom, isLegacy, overrides, key) sec, _ := SectionOfFeature(key) if !sections[sec] { allowed = false } if gates.Features != nil { if v, ok := gates.Features[key]; ok && !v { allowed = false } } features[key] = allowed if !allowed { disabled = append(disabled, key) } } sort.Strings(disabled) return features, sections, disabled } func featureETag(features map[string]bool) string { keys := make([]string, 0, len(features)) for k, v := range features { if v { keys = append(keys, k) } } sort.Strings(keys) sum := sha256.Sum256([]byte(strings.Join(keys, "\n"))) return "sha256:" + hex.EncodeToString(sum[:]) } // CapabilitiesResponseETag is a strong HTTP ETag for GET /api/billing/capabilities. // It covers the feature map plus plan identity and remaining credits so conditional // GETs do not skip wallet updates when only credits change. func CapabilitiesResponseETag(c Capabilities) string { raw := fmt.Sprintf("%s|p%d|r%d|%t|%s", c.FeatureETag, c.PlanID, c.Entitlements.RemainingCredits, c.HasActivePlan, c.PlanName) sum := sha256.Sum256([]byte(raw)) return `"` + "sha256:" + hex.EncodeToString(sum[:8]) + `"` } func validateFeatureOverrides(features map[string]bool) error { if features == nil { return nil } for k := range features { if !IsKnownFeatureKey(k) { return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k) } } return nil } func validateGatesUpdate(sections, features map[string]bool) error { for s := range sections { if !IsKnownFeatureSection(s) { return fmt.Errorf("%w: %s", ErrUnknownFeatureSection, s) } } for k := range features { if !IsKnownFeatureKey(k) { return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k) } } return nil } func decodeFeaturesJSON(raw []byte) (map[string]bool, error) { if len(raw) == 0 { return map[string]bool{}, nil } var m map[string]bool if err := json.Unmarshal(raw, &m); err != nil { return nil, err } if m == nil { m = map[string]bool{} } return m, nil } func encodeFeaturesJSON(m map[string]bool) ([]byte, error) { if m == nil { m = map[string]bool{} } return json.Marshal(m) } func emptyGatesView() FeatureGatesView { sections := make(map[string]bool, len(FeatureSections)) for _, s := range FeatureSections { sections[s] = true } return FeatureGatesView{ Sections: sections, Features: map[string]bool{}, } } func cloneGatesView(v FeatureGatesView) FeatureGatesView { out := FeatureGatesView{ Sections: make(map[string]bool, len(v.Sections)), Features: make(map[string]bool, len(v.Features)), } for k, enabled := range v.Sections { out.Sections[k] = enabled } for k, enabled := range v.Features { out.Features[k] = enabled } return out } func (s *Service) invalidateFeatureGatesCache() { if s == nil { return } s.gatesMu.Lock() s.gatesCache = nil s.gatesCachedAt = time.Time{} s.gatesMu.Unlock() } func (s *Service) storeFeatureGatesCache(view FeatureGatesView) { if s == nil { return } copied := cloneGatesView(view) s.gatesMu.Lock() s.gatesCache = &copied s.gatesCachedAt = time.Now() s.gatesMu.Unlock() } // GetFeatureGates returns global section/feature master switches (missing => enabled). func (s *Service) GetFeatureGates(ctx context.Context) (FeatureGatesView, error) { if s == nil || s.Pool == nil { return emptyGatesView(), nil } s.gatesMu.RLock() if s.gatesCache != nil && time.Since(s.gatesCachedAt) < featureGatesCacheTTL { cached := cloneGatesView(*s.gatesCache) s.gatesMu.RUnlock() return cached, nil } s.gatesMu.RUnlock() view, err := s.loadFeatureGates(ctx) if err != nil { return FeatureGatesView{}, err } s.storeFeatureGatesCache(view) return cloneGatesView(view), nil } func (s *Service) loadFeatureGates(ctx context.Context) (FeatureGatesView, error) { view := emptyGatesView() rows, err := s.Pool.Query(ctx, ` SELECT gate_key, kind, enabled FROM platform_feature_gates`) if err != nil { // Table may not exist yet (migration pending). if isUndefinedRelation(err) { return view, nil } return FeatureGatesView{}, err } defer rows.Close() for rows.Next() { var key, kind string var enabled bool if err := rows.Scan(&key, &kind, &enabled); err != nil { return FeatureGatesView{}, err } switch kind { case "section": view.Sections[key] = enabled case "feature": view.Features[key] = enabled } } if err := rows.Err(); err != nil { return FeatureGatesView{}, err } return view, nil } // SetFeatureGates upserts provided section/feature gates (partial). Omitted maps are left unchanged. func (s *Service) SetFeatureGates(ctx context.Context, sections, features map[string]bool, updatedBy *uuid.UUID) (FeatureGatesView, error) { if err := validateGatesUpdate(sections, features); err != nil { return FeatureGatesView{}, err } if s == nil || s.Pool == nil { return FeatureGatesView{}, errors.New("billing service unavailable") } tx, err := s.Pool.Begin(ctx) if err != nil { return FeatureGatesView{}, err } defer tx.Rollback(ctx) upsert := func(key, kind string, enabled bool) error { _, err := tx.Exec(ctx, ` INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at, updated_by) VALUES ($1, $2, $3, now(), $4) ON CONFLICT (gate_key) DO UPDATE SET kind = EXCLUDED.kind, enabled = EXCLUDED.enabled, updated_at = now(), updated_by = EXCLUDED.updated_by`, key, kind, enabled, updatedBy) return err } for k, v := range sections { if err := upsert(k, "section", v); err != nil { return FeatureGatesView{}, err } } for k, v := range features { if err := upsert(k, "feature", v); err != nil { return FeatureGatesView{}, err } } if err := tx.Commit(ctx); err != nil { return FeatureGatesView{}, err } s.invalidateFeatureGatesCache() return s.GetFeatureGates(ctx) } // SetSectionGate enables/disables one section for ALL plans (global master switch). func (s *Service) SetSectionGate(ctx context.Context, section string, enabled bool, updatedBy *uuid.UUID) (FeatureGatesView, error) { section = strings.TrimSpace(section) if !IsKnownFeatureSection(section) { return FeatureGatesView{}, fmt.Errorf("%w: %s", ErrUnknownFeatureSection, section) } return s.SetFeatureGates(ctx, map[string]bool{section: enabled}, nil, updatedBy) } func (s *Service) loadPlanFeaturesRow(ctx context.Context, planID int64) (name string, isCustom bool, isLegacy bool, overrides map[string]bool, err error) { if s == nil || s.Pool == nil { return "", false, false, nil, errors.New("billing service unavailable") } var raw []byte err = s.Pool.QueryRow(ctx, ` SELECT name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb) FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &isLegacy, &raw) if errors.Is(err, pgx.ErrNoRows) { return "", false, false, nil, ErrPlanNotFound } if err != nil { if isUndefinedColumn(err) { // Pre-migration: fall back without is_legacy and/or features. err = s.Pool.QueryRow(ctx, ` SELECT name, is_custom, COALESCE(features, '{}'::jsonb) FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &raw) if errors.Is(err, pgx.ErrNoRows) { return "", false, false, nil, ErrPlanNotFound } if err != nil { if isUndefinedColumn(err) { err = s.Pool.QueryRow(ctx, `SELECT name, is_custom FROM plans WHERE id = $1`, planID). Scan(&name, &isCustom) if errors.Is(err, pgx.ErrNoRows) { return "", false, false, nil, ErrPlanNotFound } if err != nil { return "", false, false, nil, err } return name, isCustom, IsLegacyPlanName(name), map[string]bool{}, nil } return "", false, false, nil, err } overrides, err = decodeFeaturesJSON(raw) if err != nil { return "", false, false, nil, err } return name, isCustom, IsLegacyPlanName(name), overrides, nil } return "", false, false, nil, err } overrides, err = decodeFeaturesJSON(raw) if err != nil { return "", false, false, nil, err } if !isLegacy { isLegacy = IsLegacyPlanName(name) } return name, isCustom, isLegacy, overrides, nil } func planFeaturesView(planID int64, name string, isCustom, isLegacy bool, overrides map[string]bool) PlanFeaturesView { if overrides == nil { overrides = map[string]bool{} } resolved := make(map[string]bool, len(FeatureCatalogKeys)) for _, k := range FeatureCatalogKeys { resolved[k] = PlanAllowsFeatureEx(name, isCustom, isLegacy, overrides, k) } return PlanFeaturesView{ PlanID: planID, PlanName: name, IsCustom: isCustom, IsLegacy: IsLegacyPlan(name, isLegacy), Features: overrides, ResolvedFeatures: resolved, } } // GetPlanFeatures returns stored overrides + plan_allows resolved matrix (globals ignored). func (s *Service) GetPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) { name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID) if err != nil { return PlanFeaturesView{}, err } return planFeaturesView(planID, name, isCustom, isLegacy, overrides), nil } // SetPlanFeatures replaces the plan's features override object. func (s *Service) SetPlanFeatures(ctx context.Context, planID int64, features map[string]bool) (PlanFeaturesView, error) { if s == nil || s.Pool == nil { return PlanFeaturesView{}, errors.New("billing service unavailable") } if features == nil { features = map[string]bool{} } if err := validateFeatureOverrides(features); err != nil { return PlanFeaturesView{}, err } raw, err := encodeFeaturesJSON(features) if err != nil { return PlanFeaturesView{}, err } tag, err := s.Pool.Exec(ctx, ` UPDATE plans SET features = $2::jsonb, updated_at = now() WHERE id = $1`, planID, raw) if err != nil { if isUndefinedColumn(err) { return PlanFeaturesView{}, errors.New("plans.features column missing — run migration 026_plan_features") } return PlanFeaturesView{}, err } if tag.RowsAffected() == 0 { return PlanFeaturesView{}, ErrPlanNotFound } return s.GetPlanFeatures(ctx, planID) } // EnableAllPlanFeatures sets every registry key to true on the plan (custom packages helper). func (s *Service) EnableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) { return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(true)) } // DisableAllPlanFeatures sets every registry key to false on the plan. func (s *Service) DisableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) { return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(false)) } // CapabilitiesForCompany returns effective features for the company's active plan ∩ globals. func (s *Service) CapabilitiesForCompany(ctx context.Context, companyID uuid.UUID) (Capabilities, error) { if s == nil || s.Pool == nil { return Capabilities{}, errors.New("billing service unavailable") } gates, err := s.GetFeatureGates(ctx) if err != nil { return Capabilities{}, err } var planID int64 var planName string var isCustom bool var isLegacy bool var monthly *int var isTrial bool var raw []byte hasPlan := false err = s.Pool.QueryRow(ctx, ` SELECT p.id, p.name, p.is_custom, COALESCE(p.is_legacy, false), p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb) FROM company_plans cp JOIN plans p ON p.id = cp.plan_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.created_at DESC LIMIT 1`, companyID). Scan(&planID, &planName, &isCustom, &isLegacy, &monthly, &isTrial, &raw) if err == nil { hasPlan = true } else if errors.Is(err, pgx.ErrNoRows) { planName = "Free" raw = []byte("{}") } else if isUndefinedColumn(err) { err = s.Pool.QueryRow(ctx, ` SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb) FROM company_plans cp JOIN plans p ON p.id = cp.plan_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.created_at DESC LIMIT 1`, companyID). Scan(&planID, &planName, &isCustom, &monthly, &isTrial, &raw) if err == nil { hasPlan = true isLegacy = IsLegacyPlanName(planName) } else if errors.Is(err, pgx.ErrNoRows) { planName = "Free" raw = []byte("{}") } else if isUndefinedColumn(err) { err = s.Pool.QueryRow(ctx, ` SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial FROM company_plans cp JOIN plans p ON p.id = cp.plan_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.created_at DESC LIMIT 1`, companyID). Scan(&planID, &planName, &isCustom, &monthly, &isTrial) if err == nil { hasPlan = true raw = []byte("{}") isLegacy = IsLegacyPlanName(planName) } else if errors.Is(err, pgx.ErrNoRows) { planName = "Free" raw = []byte("{}") } else { return Capabilities{}, err } } else { return Capabilities{}, err } } else { return Capabilities{}, err } overrides, err := decodeFeaturesJSON(raw) if err != nil { return Capabilities{}, err } var total, used int _ = s.Pool.QueryRow(ctx, `SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID). Scan(&total, &used) remaining := RemainingCreditsClamped(total, used) monthlyVal := 0 if monthly != nil { monthlyVal = *monthly } ent := ComputeEntitlements(planName, monthlyVal, remaining, isTrial) isLegacy = IsLegacyPlan(planName, isLegacy) features, sections, disabled := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates) out := Capabilities{ PlanName: planName, IsCustom: isCustom, IsLegacy: isLegacy, HasActivePlan: hasPlan, Features: features, Sections: sections, DisabledFeatures: disabled, FeatureETag: featureETag(features), Entitlements: ent, } if hasPlan { out.PlanID = planID } return out, nil } func isUndefinedRelation(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "does not exist") && strings.Contains(msg, "platform_feature_gates") } func isUndefinedColumn(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) missing := strings.Contains(msg, "does not exist") || strings.Contains(msg, "undefined column") || strings.Contains(msg, "undefined_column") if !missing { return false } // Postgres: column "x" of relation "y" does not exist — features or is_legacy pre-migration. return strings.Contains(msg, "column") || strings.Contains(msg, "features") || strings.Contains(msg, "is_legacy") }