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" } }