Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
222 lines
6.4 KiB
Go
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"
|
|
}
|
|
}
|