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,650 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user