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.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user