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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,43 @@
package billing
import "testing"
func TestCapabilitiesResponseETagStableAndSensitive(t *testing.T) {
t.Parallel()
base := Capabilities{
PlanID: 3,
PlanName: "Growth",
HasActivePlan: true,
FeatureETag: featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": false}),
Entitlements: Entitlements{RemainingCredits: 100},
}
a := CapabilitiesResponseETag(base)
b := CapabilitiesResponseETag(base)
if a == "" || a[0] != '"' || a[len(a)-1] != '"' {
t.Fatalf("etag must be quoted strong form, got %q", a)
}
if a != b {
t.Fatalf("etag unstable: %q vs %q", a, b)
}
creditChanged := base
creditChanged.Entitlements.RemainingCredits = 99
if CapabilitiesResponseETag(creditChanged) == a {
t.Fatal("etag must change when remaining credits change")
}
featChanged := base
featChanged.FeatureETag = featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": true})
if CapabilitiesResponseETag(featChanged) == a {
t.Fatal("etag must change when feature map changes")
}
}
func TestFeatureETagIgnoresDisabledKeys(t *testing.T) {
t.Parallel()
a := featureETag(map[string]bool{"a": true, "b": false})
b := featureETag(map[string]bool{"a": true})
if a != b {
t.Fatalf("disabled keys should not affect feature etag: %q vs %q", a, b)
}
}
@@ -0,0 +1,36 @@
package billing
import "errors"
var (
ErrPlanNameRequired = errors.New("name required")
ErrPlanNotFound = errors.New("plan not found")
ErrAmountRequired = errors.New("amount required")
ErrStripeNoCustomer = errors.New("no stripe customer for this company — complete a checkout first")
)
// ClientError reports whether err is a known client-facing billing/Stripe error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrPlanNameRequired),
errors.Is(err, ErrPlanNotFound),
errors.Is(err, ErrAmountRequired),
errors.Is(err, ErrStripeNotConfigured),
errors.Is(err, ErrStripePlanUnsupported),
errors.Is(err, ErrStripePriceMissing),
errors.Is(err, ErrStripeNoCustomer),
errors.Is(err, ErrInsufficientCredits),
errors.Is(err, ErrProductLimitExceeded),
errors.Is(err, ErrAIRequiresUpgrade),
errors.Is(err, ErrEPRELRequiresUpgrade),
errors.Is(err, ErrUnknownFeatureKey),
errors.Is(err, ErrUnknownFeatureSection),
errors.Is(err, ErrInvalidFeatureGates),
errors.Is(err, ErrFeatureDisabled):
return err.Error(), true
default:
return "", false
}
}
@@ -0,0 +1,200 @@
package billing
import (
"context"
"errors"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Concurrent ConsumeCredits on one company must serialize on credit_balances and
// never overspend the wallet.
func TestConsumeCreditsConcurrentNoOverspend(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-contention")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
// Paid plan keeps CanUseAI true at empty wallet so flat (0-token) debits still
// hit the atomic UPDATE and return ErrInsufficientCredits (not a Free no-op).
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "consume-contention-"+companyID.String()[:8], "integration", 100).Scan(&planID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
})
_, err = pg.Exec(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, now() - interval '1 day', now() + interval '30 days')`, companyID, planID)
if err != nil {
t.Fatal(err)
}
const wallet = 20
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, 0, now())`, companyID, wallet)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
_ = svc.EnsureDefaultCosts(ctx)
const workers = 40
var wg sync.WaitGroup
var okCount atomic.Int64
var insuff atomic.Int64
startGate := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startGate
err := svc.ConsumeCredits(ctx, companyID, 0, "product_processing")
if err == nil {
okCount.Add(1)
return
}
if errors.Is(err, ErrInsufficientCredits) {
insuff.Add(1)
return
}
t.Errorf("unexpected: %v", err)
}()
}
close(startGate)
wg.Wait()
if okCount.Load() != wallet {
t.Fatalf("ok=%d want %d (insuff=%d)", okCount.Load(), wallet, insuff.Load())
}
if okCount.Load()+insuff.Load() != workers {
t.Fatalf("ok+insuff=%d want %d", okCount.Load()+insuff.Load(), workers)
}
var used, total int
err = pg.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if err != nil {
t.Fatal(err)
}
if used != wallet || total != wallet {
t.Fatalf("wallet total=%d used=%d want total=%d used=%d", total, used, wallet, wallet)
}
var cycleUsed, products int
err = pg.QueryRow(ctx, `
SELECT credits_used, products_processed FROM billing_cycles
WHERE company_id = $1 AND end_date > now()
ORDER BY start_date DESC LIMIT 1`, companyID).Scan(&cycleUsed, &products)
if err != nil {
t.Fatal(err)
}
if cycleUsed != wallet || products != wallet {
t.Fatalf("cycle used=%d products=%d want %d", cycleUsed, products, wallet)
}
}
func TestConsumeCreditsBatchMatchesSummedBase(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-batch")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 100, 0, now())`, companyID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
_ = svc.EnsureDefaultCosts(ctx)
// 5 products, 0 tokens → DebitAmountN = 5 base credits, products_processed += 5.
if err := svc.ConsumeCreditsBatch(ctx, companyID, 0, 5, "product_processing"); err != nil {
t.Fatal(err)
}
var used, products int
err = pg.QueryRow(ctx, `
SELECT cb.used_credits, bc.products_processed
FROM credit_balances cb
JOIN billing_cycles bc ON bc.company_id = cb.company_id AND bc.end_date > now()
WHERE cb.company_id = $1
ORDER BY bc.start_date DESC LIMIT 1`, companyID).Scan(&used, &products)
if err != nil {
t.Fatal(err)
}
if used != 5 || products != 5 {
t.Fatalf("used=%d products=%d want 5/5", used, products)
}
}
+47
View File
@@ -0,0 +1,47 @@
package billing
import "testing"
func TestTokenPackMath(t *testing.T) {
// Mirrors DebitAmount pack math: ceil(tokens/1000).
cases := []struct {
tokens int
packs int
}{
{0, 0},
{1, 1},
{1000, 1},
{1001, 2},
{2500, 3},
}
for _, c := range cases {
packs := 0
if c.tokens > 0 {
packs = (c.tokens + 999) / 1000
}
got := DebitAmount(1, 1, c.tokens)
want := 1 + packs
if got != want {
t.Fatalf("tokens=%d DebitAmount=%d want %d (packs=%d)", c.tokens, got, want, packs)
}
}
}
func TestEstimateDebitMath(t *testing.T) {
// Pure pack math aligned with EstimateDebit / ConsumeCredits (costs=1).
cases := []struct {
featureTokens int
want int
}{
{0, 1}, // base feature cost only
{1, 2},
{1000, 2},
{1001, 3},
}
for _, c := range cases {
got := DebitAmount(1, 1, c.featureTokens)
if got != c.want {
t.Fatalf("tokens=%d debit=%d want %d", c.featureTokens, got, c.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package billing
import "strings"
// CreditsPerAIProduct is the typical wallet debit for one AI enhance
// (product_processing base + one openai_token_k pack when tokens ≤ 1000).
// Each content-language pass burns another ~CreditsPerAIProduct per product.
// Included monthly grants assume AssumedPrimaryContentLanguages only.
const CreditsPerAIProduct = 2
// AssumedPrimaryContentLanguages is how many content languages the included
// monthly grant is sized for. Extra languages → credit packs or BYOK.
const AssumedPrimaryContentLanguages = 1
// ScaleMaxProducts is the top self-serve SKU ceiling. Huge catalogs (1M+)
// belong on Enterprise (sales-led credits / BYOK), not public Scale.
const ScaleMaxProducts = 12_000
// PlanAICoverPercent is included monthly AI as a share of CreditSKUBase
// (primary language only). Paid public plans use ~50% cover so Starter stays
// lean (100 credits ≈ 50 AI products on a 100-SKU plan) and higher tiers
// scale with CreditSKUBase ≤ PlanMaxProducts — not a full-catalog AI bundle.
// Formula: credits = (CreditSKUBase × cover% / 100) × CreditsPerAIProduct.
func PlanAICoverPercent(planName string) int {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "starter":
return 50 // 100 × 50% × 2 = 100
case "plus":
return 50 // 400 × 50% × 2 = 400
case "growth":
return 50 // 1,200 × 50% × 2 = 1,200
case "business":
return 50 // 4,000 × 50% × 2 = 4,000
case "scale":
return 50 // 12,000 × 50% × 2 = 12,000
case "enterprise":
return 50 // display ladder only; grant is EnterpriseUnlimitedCredits
default:
return 0
}
}
// PlanMaxProducts is the hard SKU ceiling for a public plan name.
// Slow retail ladder for small→mid shops; Scale reaches ScaleMaxProducts;
// Enterprise is unlimited (nil). Credits sized via CreditSKUBase (≤ MaxProducts).
func PlanMaxProducts(planName string) *int {
mp := func(n int) *int { return &n }
switch strings.ToLower(strings.TrimSpace(planName)) {
case "free":
return mp(50)
case "starter":
return mp(100)
case "plus":
return mp(400)
case "growth":
return mp(1_200)
case "business":
return mp(4_000)
case "scale":
return mp(ScaleMaxProducts)
default:
// Enterprise and unknown custom plans — unlimited SKU cap.
return nil
}
}
// CreditSKUBase is the catalog size used ONLY to size included monthly AI credits.
// May be smaller than PlanMaxProducts so large catalogs still get a bounded AI starter grant.
// Public paid ladder: base equals PlanMaxProducts (50% cover → half-catalog primary-lang AI).
func CreditSKUBase(planName string) int {
switch strings.ToLower(strings.TrimSpace(planName)) {
case "starter":
return 100
case "plus":
return 400
case "growth":
return 1_200
case "business":
return 4_000
case "scale":
return 12_000
default:
return 0
}
}
// MonthlyCreditsForSKUCover returns credits for coverPct% of skuBase at CreditsPerAIProduct each.
func MonthlyCreditsForSKUCover(skuBase, coverPct int) int {
if skuBase <= 0 || coverPct <= 0 {
return 0
}
if coverPct > 100 {
coverPct = 100
}
products := (skuBase * coverPct) / 100
return products * CreditsPerAIProduct * AssumedPrimaryContentLanguages
}
// MonthlyCreditsForPlan sizes the monthly grant from CreditSKUBase × PlanAICoverPercent.
// The maxProducts argument is ignored when CreditSKUBase is set (paid public ladder).
func MonthlyCreditsForPlan(planName string, maxProducts int) int {
base := CreditSKUBase(planName)
if base <= 0 {
base = maxProducts
}
return MonthlyCreditsForSKUCover(base, PlanAICoverPercent(planName))
}
// CreditPack is a one-time AI credit top-up sold via Stripe Checkout (mode=payment).
// These are additional Stripe Products with one-time Prices — not subscription add-ons.
type CreditPack struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Credits int `json:"credits"`
PriceUSD int `json:"price_usd"` // whole dollars for marketing UI
// Approx product×language passes at CreditsPerAIProduct.
AIProducts int `json:"ai_products"`
}
func packFromCredits(id, name, desc string, credits, priceUSD int) CreditPack {
return CreditPack{
ID: id,
Name: name,
Description: desc,
Credits: credits,
PriceUSD: priceUSD,
AIProducts: credits / CreditsPerAIProduct,
}
}
// DefaultCreditPacks is the public top-up ladder (credits-first sizing).
// Prices are balanced so small packs are not punitive $/credit vs larger ones,
// while staying expensive enough that packs cannot undercut plan upgrades or A1 (~€300).
func DefaultCreditPacks() []CreditPack {
return []CreditPack{
packFromCredits("tiny", "Nano pack", "Smoke tests / tiny fixes (25 credits ≈ 12 AI products)", 25, 29),
packFromCredits("small", "Starter pack", "Small top-up (65 credits ≈ 32 AI products)", 65, 59),
packFromCredits("medium", "Plus pack", "Burst top-up (200 credits ≈ 100 AI products)", 200, 149),
packFromCredits("large", "Growth pack", "Mid buffer (500 credits ≈ 250 AI products)", 500, 299),
packFromCredits("xl", "Catalog pack", "Catalog / re-run buffer (1,200 credits ≈ 600 AI products)", 1200, 599),
packFromCredits("xxl", "Business pack", "Large multi-language buffer (3,000 credits ≈ 1,500 AI products)", 3000, 1299),
packFromCredits("mega", "Scale pack", "Distributor / agency burst (8,000 credits ≈ 4,000 AI products)", 8000, 2999),
}
}
// CreditPackByID returns a pack from DefaultCreditPacks.
func CreditPackByID(id string) (CreditPack, bool) {
want := strings.ToLower(strings.TrimSpace(id))
for _, p := range DefaultCreditPacks() {
if p.ID == want {
return p, true
}
}
return CreditPack{}, false
}
// CreditPackPriceKey is the Stripe PriceIDs map key for a one-time pack (pack:<id>).
func CreditPackPriceKey(packID string) string {
return "pack:" + strings.ToLower(strings.TrimSpace(packID))
}
// CreditPackSettingsKey is the platformsettings Values key (stripe.price.pack.<id>).
func CreditPackSettingsKey(packID string) string {
return "stripe.price.pack." + strings.ToLower(strings.TrimSpace(packID))
}
// CreditPackEnvVar is the optional process-env fallback (STRIPE_PRICE_PACK_<ID>).
func CreditPackEnvVar(packID string) string {
return "STRIPE_PRICE_PACK_" + strings.ToUpper(strings.TrimSpace(packID))
}
@@ -0,0 +1,122 @@
package billing
import (
"errors"
"testing"
)
func TestRemainingCreditsClamped(t *testing.T) {
cases := []struct {
total, used, want int
}{
{100, 40, 60},
{100, 100, 0},
{100, 150, 0}, // used > total must not report negative
{0, 0, 0},
{0, 5, 0},
}
for _, c := range cases {
got := RemainingCreditsClamped(c.total, c.used)
if got != c.want {
t.Fatalf("total=%d used=%d got=%d want=%d", c.total, c.used, got, c.want)
}
}
}
func TestApplyCreditDeltaPreventsNegativeRemaining(t *testing.T) {
cases := []struct {
total, used, amount, want int
}{
{100, 20, 50, 150},
{100, 80, -50, 80}, // clawback stops at used
{100, 80, -200, 80},
{10, 0, -5, 5},
{10, 0, -20, 0},
{0, 0, 25, 25},
}
for _, c := range cases {
got := ApplyCreditDelta(c.total, c.used, c.amount)
if got != c.want {
t.Fatalf("total=%d used=%d amount=%d got=%d want=%d", c.total, c.used, c.amount, got, c.want)
}
if got < c.used {
t.Fatalf("total after delta below used: got=%d used=%d", got, c.used)
}
}
}
func TestComputeEntitlementsClampsNegativeRemaining(t *testing.T) {
ent := ComputeEntitlements("Free", 0, -10, false)
if ent.RemainingCredits != 0 {
t.Fatalf("remaining=%d want 0", ent.RemainingCredits)
}
if ent.CanUseAI {
t.Fatal("negative remaining on Free must not unlock AI")
}
}
func TestConsumeCreditsContractErrors(t *testing.T) {
// Document sentinel used by processOne / ProcessJob abort path.
if !errors.Is(ErrInsufficientCredits, ErrInsufficientCredits) {
t.Fatal("sentinel self-match")
}
wrapped := errors.New("x")
if errors.Is(wrapped, ErrInsufficientCredits) {
t.Fatal("unrelated error must not match")
}
}
func TestDebitFloorAndNegativeTokens(t *testing.T) {
if got := DebitAmount(1, 1, -5); got != 1 {
t.Fatalf("DebitAmount negative tokens=%d want 1", got)
}
}
func TestDebitAmount(t *testing.T) {
cases := []struct {
feature, tokenK, tokens, want int
}{
{1, 1, 0, 1},
{1, 1, 1, 2},
{1, 1, 1000, 2},
{1, 1, 1001, 3},
{2, 3, 2500, 2 + 3*3}, // base 2 + 3 packs * 3
{0, 0, 0, 1}, // floors
}
for _, c := range cases {
got := DebitAmount(c.feature, c.tokenK, c.tokens)
if got != c.want {
t.Fatalf("DebitAmount(%d,%d,%d)=%d want %d", c.feature, c.tokenK, c.tokens, got, c.want)
}
}
}
func TestDebitAmountNVsPerProduct(t *testing.T) {
// Exact parity when each item's tokens don't leave partial packs that merge.
sum := DebitAmount(1, 1, 1000) + DebitAmount(1, 1, 1000)
batchedExact := DebitAmountN(1, 1, 2000, 2)
if sum != batchedExact {
t.Fatalf("aligned packs: sum=%d batch=%d", sum, batchedExact)
}
// Combined packs can undercharge vs per-item ceil.
perItem := DebitAmount(1, 1, 500) + DebitAmount(1, 1, 500) // 2+2=4
batched := DebitAmountN(1, 1, 1000, 2) // 2*1 + 1 = 3
if perItem <= batched {
t.Fatalf("expected batch undercharge: perItem=%d batched=%d", perItem, batched)
}
}
func TestConsumeCreditsSkipsEntitlementsOnAITokens(t *testing.T) {
// Document hot-path contract: tokenCount > 0 skips EntitlementsForCompany.
// Flat (0-token) Free-plan burn still gates via !CanUseAI.
tokenCount := 1200
needEntitlements := tokenCount == 0
if needEntitlements {
t.Fatal("AI token debit must not require entitlements preflight")
}
tokenCount = 0
if !(tokenCount == 0) {
t.Fatal("flat debit still gates entitlements")
}
}
@@ -0,0 +1,130 @@
package billing
import (
"context"
"strings"
"github.com/google/uuid"
)
// IsCustomPackage reports whether a plan should get the "custom deal" feature
// treatment (all dashboard features ON by default for non-A1 deals).
//
// Product semantics (see IsPublicProductPlan + plans.is_custom):
// - Client / admin deals with is_custom=true → custom (including A1 PAYG)
// - Public Enterprise (and any row with is_custom=true) → custom
// - Exact "Legacy" plan name → never enable-all (restricted migrated matrix)
// - Free / Starter / Growth / Business with is_custom=false → not custom
//
// is_custom wins over A1* name patterns for PAYG billing / PlanProfileCustom,
// but A1* custom deals use A1PaygPlanFeatures (not literal enable-all) so
// Stores, Marketing, and Integrations stay off.
func IsCustomPackage(name string, isCustom bool) bool {
if strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
return false
}
if isCustom {
return true
}
// Legacy-named plans without is_custom stay on the limited matrix.
if IsLegacyPlanName(name) {
return false
}
return !IsPublicProductPlan(name)
}
// A1PaygFeatureDenied reports keys that stay OFF on A1 PAYG custom deals.
// Nav: Stores → stores.*; Marketing → marketing.*; Integrations → integrations.*.
// Cutover honesty chrome (ETL gaps / reconnect / migrated checklist) stays OFF — A1 is not a
// hypercare merchant surface (see MigratedEtlGapsPanel + IsA1CohortCompany product rules).
func A1PaygFeatureDenied(key string) bool {
switch key {
case "dashboard.etl_gaps", "dashboard.store_reconnect", "dashboard.migrated_checklist":
return true
}
return strings.HasPrefix(key, "stores.") ||
strings.HasPrefix(key, "marketing.") ||
strings.HasPrefix(key, "integrations.")
}
// A1PaygPlanFeatures is the dump-faithful A1 PAYG matrix: custom enable-all
// minus Stores, Marketing, and Integrations sections.
func A1PaygPlanFeatures() map[string]bool {
out := AllRegistryFeatures(true)
for k := range out {
if A1PaygFeatureDenied(k) {
out[k] = false
}
}
return out
}
// SparseA1PaygOverrides returns explicit false overrides for A1 PAYG denied keys.
func SparseA1PaygOverrides() map[string]bool {
out := make(map[string]bool)
for _, k := range FeatureCatalogKeys {
if A1PaygFeatureDenied(k) {
out[k] = false
}
}
return out
}
// AllRegistryFeatures returns every FeatureCatalogKeys entry set to enabled.
func AllRegistryFeatures(enabled bool) map[string]bool {
out := make(map[string]bool, len(FeatureCatalogKeys))
for _, k := range FeatureCatalogKeys {
out[k] = enabled
}
return out
}
// EnableSectionForAllPlans turns a section master switch ON for every plan
// (global gate; missing rows already default ON).
func (s *Service) EnableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
return s.SetSectionGate(ctx, section, true, updatedBy)
}
// DisableSectionForAllPlans turns a section master switch OFF for every plan.
func (s *Service) DisableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
return s.SetSectionGate(ctx, section, false, updatedBy)
}
// prepareCustomPackageCreateFeatures applies create-time defaults for custom packages:
// when the caller omitted features, materialize enable-all overrides so admin UIs
// show an explicit all-ON matrix (resolve already treats empty+is_custom as all ON).
func prepareCustomPackageCreateFeatures(p *Plan, creating, featuresProvided bool) {
if !creating {
return
}
if IsLegacyPlan(p.Name, p.IsLegacy) && !IsCustomPackage(p.Name, p.IsCustom) {
p.IsLegacy = true
if strings.EqualFold(strings.TrimSpace(p.Name), LegacyPlanName) {
p.IsCustom = false
} else if !IsPublicProductPlan(p.Name) {
p.IsCustom = true
}
if !featuresProvided {
p.Features = SparseLegacyOverrides()
}
return
}
// Non-public ladder names are client deals — keep is_custom aligned.
if !IsPublicProductPlan(p.Name) {
p.IsCustom = true
}
// A1 PAYG / custom deals are never the restricted Legacy matrix.
if IsCustomPackage(p.Name, p.IsCustom) {
p.IsLegacy = false
}
if featuresProvided {
return
}
if IsCustomPackage(p.Name, p.IsCustom) {
if IsLegacyPlanName(p.Name) {
p.Features = A1PaygPlanFeatures()
return
}
p.Features = AllRegistryFeatures(true)
}
}
@@ -0,0 +1,188 @@
package billing
import "testing"
func TestIsCustomPackage(t *testing.T) {
cases := []struct {
name string
isCustom bool
want bool
}{
{"Free", false, false},
{"Starter", false, false},
{"Growth", false, false},
{"Business", false, false},
{"Enterprise", true, true},
{"Enterprise", false, false}, // public ladder without is_custom flag
{"A1", false, false}, // legacy name without is_custom → limited matrix
{"A1", true, true}, // dump-faithful A1 PAYG is_custom → custom profile (Stores/AI still gated)
{"Legacy", true, false}, // exact Legacy package never enable-all
{"Merkur trial", false, true},
{" growth ", false, false},
{"", false, true}, // empty name is not a public plan name
}
for _, tc := range cases {
got := IsCustomPackage(tc.name, tc.isCustom)
if got != tc.want {
t.Fatalf("IsCustomPackage(%q, %v)=%v want %v", tc.name, tc.isCustom, got, tc.want)
}
}
}
func TestAllRegistryFeatures(t *testing.T) {
on := AllRegistryFeatures(true)
off := AllRegistryFeatures(false)
if len(on) != len(FeatureCatalogKeys) || len(off) != len(FeatureCatalogKeys) {
t.Fatalf("len on=%d off=%d catalog=%d", len(on), len(off), len(FeatureCatalogKeys))
}
for _, k := range FeatureCatalogKeys {
if !on[k] {
t.Fatalf("expected %s enabled", k)
}
if off[k] {
t.Fatalf("expected %s disabled", k)
}
}
}
func TestPrepareCustomPackageCreateFeatures(t *testing.T) {
t.Run("custom create without features enables all", func(t *testing.T) {
p := Plan{Name: "ClientCo Deal", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.Features == nil || len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected full enable-all features, got %#v", p.Features)
}
for _, k := range FeatureCatalogKeys {
if !p.Features[k] {
t.Fatalf("key %s not enabled", k)
}
}
})
t.Run("non-public name forces is_custom", func(t *testing.T) {
p := Plan{Name: "Merkur", IsCustom: false}
prepareCustomPackageCreateFeatures(&p, true, false)
if !p.IsCustom {
t.Fatal("expected is_custom forced true for client deal")
}
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected enable-all after force custom, got %d keys", len(p.Features))
}
})
t.Run("public free create leaves features nil", func(t *testing.T) {
p := Plan{Name: "Free", IsCustom: false}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.Features != nil {
t.Fatalf("standard Free must not materialize features: %#v", p.Features)
}
})
t.Run("explicit features respected", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true, Features: map[string]bool{"catalog.products": false}}
prepareCustomPackageCreateFeatures(&p, true, true)
if p.Features["catalog.products"] != false || len(p.Features) != 1 {
t.Fatalf("explicit features overwritten: %#v", p.Features)
}
})
t.Run("update does not rewrite", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true, ID: 9}
prepareCustomPackageCreateFeatures(&p, false, false)
if p.Features != nil {
t.Fatalf("update must not inject features: %#v", p.Features)
}
})
t.Run("enterprise is_custom create enables all", func(t *testing.T) {
p := Plan{Name: "Enterprise", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("enterprise custom create should enable all, got %d", len(p.Features))
}
})
t.Run("A1 custom create uses PAYG matrix without Stores/Marketing/Integrations", func(t *testing.T) {
p := Plan{Name: "A1", IsCustom: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if p.IsLegacy {
t.Fatal("A1 PAYG create must clear is_legacy")
}
if !p.IsCustom {
t.Fatal("A1 remains a client deal (is_custom)")
}
if !p.Features["processing.monitor"] {
t.Fatal("A1 PAYG must enable processing.monitor")
}
if !p.Features["capability.eprel"] {
t.Fatal("A1 PAYG must enable capability.eprel")
}
if p.Features["stores.hub"] || p.Features["marketing.campaigns"] || p.Features["integrations.ai"] || p.Features["integrations.email"] {
t.Fatal("A1 PAYG must deny stores, marketing, and integrations")
}
if len(p.Features) != len(FeatureCatalogKeys) {
t.Fatalf("expected full tailored matrix, got %d keys", len(p.Features))
}
})
t.Run("Legacy create seeds legacy sparse", func(t *testing.T) {
p := Plan{Name: "Legacy", IsCustom: false, IsLegacy: true}
prepareCustomPackageCreateFeatures(&p, true, false)
if !p.IsLegacy {
t.Fatal("Legacy create must set is_legacy")
}
if p.Features["processing.monitor"] {
t.Fatal("Legacy must not enable processing.monitor")
}
})
}
func TestDefaultPlanFeaturesCustomUsesIsCustomPackage(t *testing.T) {
// Non-legacy client deal without is_custom flag still all-ON via name.
m := DefaultPlanFeatures("ClientCo Deal", false)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("client deal default missing %s", k)
}
}
// A1 without is_custom stays legacy — limited matrix.
a1 := DefaultPlanFeatures("A1", false)
if a1["processing.monitor"] {
t.Fatal("legacy A1 (is_custom=false) must keep processing.monitor off")
}
a1Payg := DefaultPlanFeatures("A1", true)
if !a1Payg["processing.monitor"] || !a1Payg["capability.eprel"] {
t.Fatal("A1 PAYG is_custom must enable core PAYG features")
}
if a1Payg["stores.hub"] || a1Payg["stores.shopify"] || a1Payg["marketing.campaigns"] || a1Payg["integrations.ai"] || a1Payg["integrations.ai.byok"] || a1Payg["integrations.email"] {
t.Fatal("A1 PAYG must keep Stores, Marketing, and Integrations off")
}
free := DefaultPlanFeatures("Free", false)
if free["capability.ai_processing"] {
t.Fatal("Free should keep AI processing off by default")
}
}
func TestPlanAllowsFeatureCustomByName(t *testing.T) {
if !PlanAllowsFeature("Merkur trial", false, nil, "capability.byok") {
t.Fatal("non-public package should allow all keys when overrides empty")
}
if PlanAllowsFeature("Free", false, nil, "capability.byok") {
t.Fatal("Free should deny byok by default")
}
}
func TestResolveEffectiveFeaturesSectionGate(t *testing.T) {
gates := emptyGatesView()
gates.Sections["marketing"] = false
features, sections, disabled := ResolveEffectiveFeatures("Merkur", true, nil, gates)
if sections["marketing"] {
t.Fatal("marketing section should be off")
}
if features["marketing.campaigns"] {
t.Fatal("marketing.campaigns should be effective-false when section off")
}
found := false
for _, d := range disabled {
if d == "marketing.campaigns" {
found = true
break
}
}
if !found {
t.Fatal("marketing.campaigns should appear in disabled list")
}
}
@@ -0,0 +1,245 @@
package billing
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Concurrent claimAndRollDueCompanyPlan must roll a due company_plan exactly once.
func TestClaimAndRollDueCompanyPlanConcurrent(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-claim-test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "claim-test-plan-"+companyID.String()[:8], "integration", 100).Scan(&planID)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
})
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
nextBill := time.Now().UTC().Add(-time.Hour)
var rowID int64
err = pg.QueryRow(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, $3, $4)
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 100, 17, now())`, companyID)
if err != nil {
t.Fatal(err)
}
svc := &Service{Pool: pg}
const workers = 8
var wg sync.WaitGroup
errs := make(chan error, workers)
oks := make(chan bool, workers)
startGate := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startGate
ok, runErr := svc.claimAndRollDueCompanyPlan(ctx, rowID)
if runErr != nil {
errs <- runErr
return
}
oks <- ok
}()
}
close(startGate)
wg.Wait()
close(errs)
close(oks)
for err := range errs {
t.Fatalf("claimAndRollDueCompanyPlan: %v", err)
}
successes := 0
for ok := range oks {
if ok {
successes++
}
}
if successes != 1 {
t.Fatalf("expected exactly 1 successful claim, got %d", successes)
}
var cycleCount int
err = pg.QueryRow(ctx, `SELECT COUNT(*) FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&cycleCount)
if err != nil {
t.Fatal(err)
}
if cycleCount != 1 {
t.Fatalf("billing_cycles rows=%d want 1", cycleCount)
}
var creditsUsed int
err = pg.QueryRow(ctx, `
SELECT credits_used FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&creditsUsed)
if err != nil {
t.Fatal(err)
}
if creditsUsed != 17 {
t.Fatalf("credits_used=%d want 17", creditsUsed)
}
var stillDue bool
err = pg.QueryRow(ctx, `
SELECT next_billing_date <= now()
FROM company_plans WHERE id = $1`, rowID).Scan(&stillDue)
if err != nil {
t.Fatal(err)
}
if stillDue {
t.Fatal("company_plans still due after roll")
}
var total, used int
err = pg.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total, &used)
if err != nil {
t.Fatal(err)
}
if total != 100 || used != 0 {
t.Fatalf("credit_balances total=%d used=%d want 100/0", total, used)
}
}
func TestRunDueBillingCyclesBestEffortMultiCompany(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
type fixture struct {
companyID uuid.UUID
planID int64
rowID int64
}
var fixtures []fixture
for i := 0; i < 2; i++ {
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-multi-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
cid := companyID
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, cid)
})
var planID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, term)
VALUES ($1, $2, $3, 'monthly')
RETURNING id`, "multi-plan-"+companyID.String()[:8], "integration", 80).Scan(&planID)
if err != nil {
t.Fatal(err)
}
pid := planID
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, pid)
})
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
nextBill := time.Now().UTC().Add(-time.Hour)
var rowID int64
err = pg.QueryRow(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
VALUES ($1, $2, true, $3, $4)
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
if err != nil {
t.Fatal(err)
}
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, 80, 3, now())`, companyID)
if err != nil {
t.Fatal(err)
}
fixtures = append(fixtures, fixture{companyID: companyID, planID: planID, rowID: rowID})
}
svc := &Service{Pool: pg}
res, runErr := svc.RunDueBillingCycles(ctx)
if runErr != nil {
t.Fatalf("RunDueBillingCycles: %v", runErr)
}
if res.Processed < 2 {
t.Fatalf("processed=%d want >=2 (got failed=%d)", res.Processed, res.Failed)
}
if res.Failed != 0 {
t.Fatalf("failed=%d want 0", res.Failed)
}
for _, f := range fixtures {
var stillDue bool
err = pg.QueryRow(ctx, `
SELECT next_billing_date <= now()
FROM company_plans WHERE id = $1`, f.rowID).Scan(&stillDue)
if err != nil {
t.Fatal(err)
}
if stillDue {
t.Fatalf("company_plan %d still due after multi-company run", f.rowID)
}
}
}
@@ -0,0 +1,99 @@
package billing
import (
"errors"
"strings"
"testing"
)
func TestRecordDueCycleAttempt(t *testing.T) {
permanent := errors.New("insert failed")
cases := []struct {
name string
ok bool
err error
wantProcessed int
wantFailed int
wantErrSubstr string
wantWrapped error
}{
{
name: "success",
ok: true,
wantProcessed: 1,
},
{
name: "skipped claim is neither processed nor failed",
ok: false,
},
{
name: "permanent failure increments failed and wraps",
err: permanent,
wantFailed: 1,
wantErrSubstr: "company_plan 42:",
wantWrapped: permanent,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var res DueBillingCyclesResult
gotErr := recordDueCycleAttempt(&res, 42, tc.ok, tc.err)
if res.Processed != tc.wantProcessed || res.Failed != tc.wantFailed {
t.Fatalf("processed=%d failed=%d want processed=%d failed=%d",
res.Processed, res.Failed, tc.wantProcessed, tc.wantFailed)
}
if tc.wantErrSubstr == "" {
if gotErr != nil {
t.Fatalf("unexpected err: %v", gotErr)
}
return
}
if gotErr == nil {
t.Fatal("expected error")
}
if !strings.Contains(gotErr.Error(), tc.wantErrSubstr) {
t.Fatalf("err=%q missing %q", gotErr.Error(), tc.wantErrSubstr)
}
if tc.wantWrapped != nil && !errors.Is(gotErr, tc.wantWrapped) {
t.Fatalf("errors.Is(%v, %v)=false", gotErr, tc.wantWrapped)
}
})
}
}
func TestRecordDueCycleAttemptBestEffortAggregation(t *testing.T) {
var res DueBillingCyclesResult
var errs []error
for _, attempt := range []struct {
rowID int64
ok bool
err error
}{
{1, true, nil},
{2, false, errors.New("update failed")},
{3, false, nil},
{4, true, nil},
{5, false, errors.New("commit failed")},
} {
if attemptErr := recordDueCycleAttempt(&res, attempt.rowID, attempt.ok, attempt.err); attemptErr != nil {
errs = append(errs, attemptErr)
}
}
if res.Processed != 2 || res.Failed != 2 {
t.Fatalf("processed=%d failed=%d want 2/2", res.Processed, res.Failed)
}
joined := errors.Join(errs...)
if joined == nil {
t.Fatal("expected aggregated error")
}
msg := joined.Error()
for _, want := range []string{"company_plan 2:", "company_plan 5:", "update failed", "commit failed"} {
if !strings.Contains(msg, want) {
t.Fatalf("aggregated err %q missing %q", msg, want)
}
}
}
@@ -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"
}
}
@@ -0,0 +1,141 @@
package billing
import (
"testing"
)
func TestDefaultPlanFeaturesMatrix(t *testing.T) {
t.Parallel()
free := DefaultPlanFeatures("Free", false)
if len(free) != len(FeatureCatalogKeys) {
t.Fatalf("free matrix size=%d want %d", len(free), len(FeatureCatalogKeys))
}
for _, k := range []string{
"catalog.products",
"catalog.products.process_categories",
"capability.normalize_specs_fill",
"capability.eprel",
"feeds.list",
"billing.overview",
} {
if !free[k] {
t.Fatalf("free should allow %s", k)
}
}
for _, k := range []string{
"catalog.products.process_ai_titles",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"settings.api_keys",
"capability.ai_processing",
"capability.byok",
"integrations.ai.byok",
} {
if free[k] {
t.Fatalf("free should deny %s", k)
}
}
starter := DefaultPlanFeatures("Starter", false)
if !starter["catalog.products.process_ai_titles"] {
t.Fatal("starter should allow AI titles")
}
if starter["integrations.ai.byok"] || starter["capability.byok"] {
t.Fatal("starter should deny BYOK")
}
growth := DefaultPlanFeatures("Growth", false)
for _, k := range FeatureCatalogKeys {
if !growth[k] {
t.Fatalf("growth should allow all keys; %s is off", k)
}
}
business := DefaultPlanFeatures("Business", false)
for _, k := range FeatureCatalogKeys {
if !business[k] {
t.Fatalf("business should allow all keys; %s is off", k)
}
}
enterprise := DefaultPlanFeatures("Enterprise", true)
for _, k := range FeatureCatalogKeys {
if !enterprise[k] {
t.Fatalf("enterprise custom should allow all keys; %s is off", k)
}
}
custom := DefaultPlanFeatures("ClientCo Deal", true)
for _, k := range FeatureCatalogKeys {
if !custom[k] {
t.Fatalf("custom should allow all keys; %s is off", k)
}
}
}
func TestSparseDefaultOverrides(t *testing.T) {
t.Parallel()
free := SparseDefaultOverrides("Free", false)
if len(free) == 0 {
t.Fatal("free sparse overrides should list denied keys")
}
for k, v := range free {
if v {
t.Fatalf("sparse override for %s should be false", k)
}
}
if SparseDefaultOverrides("Growth", false) == nil {
t.Fatal("expected empty map not nil")
}
if len(SparseDefaultOverrides("Growth", false)) != 0 {
t.Fatal("growth sparse should be empty")
}
if len(SparseDefaultOverrides("Anything", true)) != 0 {
t.Fatal("custom sparse should be empty")
}
if len(SparseDefaultOverrides("A1", false)) == 0 {
t.Fatal("legacy A1 sparse should list denied keys")
}
a1PaygSparse := SparseDefaultOverrides("A1", true)
if len(a1PaygSparse) == 0 {
t.Fatal("A1 PAYG custom sparse should list Stores/AI denied keys")
}
if a1PaygSparse["stores.hub"] != false || a1PaygSparse["integrations.ai"] != false {
t.Fatalf("A1 PAYG sparse must deny stores/AI: %#v", a1PaygSparse)
}
if _, ok := a1PaygSparse["processing.monitor"]; ok {
t.Fatal("A1 PAYG sparse must not list allowed keys")
}
}
func TestNormalizePublicPlanName(t *testing.T) {
t.Parallel()
cases := map[string]string{
"": "free",
"Free": "free",
"STARTER": "starter",
"Growth": "growth",
"Business": "business",
"Enterprise": "enterprise",
"A1": "other",
}
for in, want := range cases {
if got := NormalizePublicPlanName(in); got != want {
t.Fatalf("NormalizePublicPlanName(%q)=%q want %q", in, got, want)
}
}
}
func TestPlanAllowsFeatureUsesOverrides(t *testing.T) {
t.Parallel()
if PlanAllowsFeature("Free", false, map[string]bool{"settings.api_keys": true}, "settings.api_keys") != true {
t.Fatal("override true should win on free")
}
if PlanAllowsFeature("Free", false, nil, "settings.api_keys") != false {
t.Fatal("free default denies api keys")
}
if PlanAllowsFeature("Deal", true, nil, "settings.api_keys") != true {
t.Fatal("custom allows all")
}
}
+190
View File
@@ -0,0 +1,190 @@
package billing
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Entitlements describes plan-gated capabilities for a company.
type Entitlements struct {
PlanName string `json:"plan_name"`
IsFreePlan bool `json:"is_free_plan"`
IsPaidPlan bool `json:"is_paid_plan"`
IsTrial bool `json:"is_trial"`
MonthlyCredits int `json:"monthly_credits"`
RemainingCredits int `json:"remaining_credits"`
// CanUseAI is true when remaining credits > 0 OR the company is on a paid plan (not Free).
CanUseAI bool `json:"can_use_ai"`
// CanUseEPREL is always true: EU EPREL is public free data (no credits). Platform may still disable the enricher via eprel.enabled / EPREL_ENABLED.
CanUseEPREL bool `json:"can_use_eprel"`
}
// ErrAIRequiresUpgrade is returned when a job is AI-only and the company cannot use AI.
var ErrAIRequiresUpgrade = errors.New("ai features require a paid plan or AI credits")
// ErrEPRELRequiresUpgrade is retained for API error shaping only; CanUseEPREL is always true
// (public EU data, no credits). Do not surface "upgrade for EPREL" in product copy.
var ErrEPRELRequiresUpgrade = errors.New("EPREL enrichment unavailable")
// IsFreePlanName reports whether the plan name is the forever-free tier.
func IsFreePlanName(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "free")
}
// RemainingCreditsClamped returns max(0, total-used) so corrupted wallets never report negative spendable credits.
func RemainingCreditsClamped(total, used int) int {
r := total - used
if r < 0 {
return 0
}
return r
}
// ApplyCreditDelta returns the next total_credits after amount (grants or clawbacks).
// Never below used_credits or below 0 — prevents negative remaining balances.
func ApplyCreditDelta(total, used, amount int) int {
next := total + amount
if next < used {
next = used
}
if next < 0 {
next = 0
}
return next
}
// DebitAmount is the per-product credit burn: feature base + ceil(tokens/1000)*tokenK.
// Negative tokenCount is treated as 0. Costs below 1 fall back to 1.
func DebitAmount(featureCost, tokenKCost, tokenCount int) int {
if featureCost < 1 {
featureCost = 1
}
if tokenCount < 0 {
tokenCount = 0
}
debit := featureCost
if tokenCount > 0 {
if tokenKCost < 1 {
tokenKCost = 1
}
packs := (tokenCount + 999) / 1000
debit += packs * tokenKCost
}
if debit < 1 {
debit = 1
}
return debit
}
// DebitAmountN scales the feature base by productCount and adds token packs on the
// combined tokenCount. packs(sum) can be less than sum(packs) — for exact parity with
// N×ConsumeCredits, sum DebitAmount per item instead of using this helper.
func DebitAmountN(featureCost, tokenKCost, tokenCount, productCount int) int {
if productCount < 1 {
productCount = 1
}
if featureCost < 1 {
featureCost = 1
}
if tokenCount < 0 {
tokenCount = 0
}
debit := featureCost * productCount
if tokenCount > 0 {
if tokenKCost < 1 {
tokenKCost = 1
}
packs := (tokenCount + 999) / 1000
debit += packs * tokenKCost
}
if debit < 1 {
debit = 1
}
return debit
}
// ComputeEntitlements builds entitlements from plan + wallet state (pure; testable).
func ComputeEntitlements(planName string, monthlyCredits, remaining int, isTrial bool) Entitlements {
if remaining < 0 {
remaining = 0
}
free := IsFreePlanName(planName) || planName == ""
paid := !free
canAI := remaining > 0 || paid
return Entitlements{
PlanName: planName,
IsFreePlan: free,
IsPaidPlan: paid,
IsTrial: isTrial,
MonthlyCredits: monthlyCredits,
RemainingCredits: remaining,
CanUseAI: canAI,
CanUseEPREL: true,
}
}
// EntitlementsForCompany loads active plan + credit wallet entitlements.
func (s *Service) EntitlementsForCompany(ctx context.Context, companyID uuid.UUID) (Entitlements, error) {
var total, used int
err := s.Pool.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if errors.Is(err, pgx.ErrNoRows) {
total, used = 0, 0
} else if err != nil {
return Entitlements{}, err
}
remaining := RemainingCreditsClamped(total, used)
var planName string
var monthly int
var isTrial bool
err = s.Pool.QueryRow(ctx, `
SELECT COALESCE(p.name, ''), COALESCE(p.monthly_credits, 0), 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(&planName, &monthly, &isTrial)
if errors.Is(err, pgx.ErrNoRows) {
return ComputeEntitlements("Free", 0, remaining, false), nil
}
if err != nil {
return Entitlements{}, err
}
return ComputeEntitlements(planName, monthly, remaining, isTrial), nil
}
// ProcessingTypeRequiresAI reports whether the request is an AI-only intent
// (cannot be silently downgraded to normalize/specs/fill).
func ProcessingTypeRequiresAI(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "enhance", "enhance_only", "enhance-only", "title", "description", "seo", "seo_ai":
return true
default:
return false
}
}
// ProcessingTypeRequiresEPREL reports whether the request is EPREL-only.
func ProcessingTypeRequiresEPREL(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "eprel", "eprel_only":
return true
default:
return false
}
}
// ProcessingTypeIsEmailCampaignAI is reserved for future email-campaign AI endpoints.
func ProcessingTypeIsEmailCampaignAI(processingType string) bool {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "email_campaign", "email_campaign_ai", "campaign_ai":
return true
default:
return false
}
}
@@ -0,0 +1,318 @@
package billing
// Code generated from docs/plan-permissions/01-feature-keys.json — do not hand-edit keys.
// FeatureCatalogKeys is the admin-validated registry of dashboard feature keys.
var FeatureCatalogKeys = []string{
"shell.navigation",
"shell.command_palette",
"shell.company_switcher",
"shell.support_notifications",
"shell.tutorial",
"shell.account_menu",
"shell.billing_recovery_banner",
"dashboard.overview",
"dashboard.stats",
"dashboard.quick_links",
"dashboard.recent_jobs",
"dashboard.news_feed",
"dashboard.activation_checklist",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
"dashboard.store_reconnect",
"dashboard.upgrade_banners",
"catalog.products",
"catalog.products.tab_processed",
"catalog.products.tab_needs_review",
"catalog.products.tab_error",
"catalog.products.tab_processing",
"catalog.products.tab_unprocessed",
"catalog.products.process_categories",
"catalog.products.process_attributes",
"catalog.products.process_ai_titles",
"catalog.products.process_ai_descriptions",
"catalog.products.enrichment_review",
"catalog.products.export_selection",
"catalog.products.upgrade_prompt",
"catalog.categories",
"catalog.categories.title_formula",
"catalog.categories.description_formula",
"catalog.attributes",
"catalog.attributes.bulk_import",
"catalog.standard_fields",
"catalog.standard_fields.groups",
"catalog.structured_descriptions",
"catalog.vector_categories",
"feeds.list",
"feeds.add_url",
"feeds.add_csv",
"feeds.sync",
"feeds.mapping",
"feeds.mapping.select_item",
"feeds.mapping.map_fields",
"feeds.export_feeds",
"feeds.export_feeds.create",
"feeds.export_feeds.generate",
"feeds.uploads",
"stores.hub",
"stores.woocommerce",
"stores.woocommerce.connection",
"stores.woocommerce.categories",
"stores.woocommerce.attributes",
"stores.woocommerce.orders",
"stores.woocommerce.reviews",
"stores.woocommerce.settings",
"stores.shopify",
"stores.shopify.connection",
"stores.shopify.orders",
"stores.shopify.settings",
"processing.monitor",
"marketing.campaigns",
"marketing.campaigns.create",
"marketing.campaigns.generate_ai",
"marketing.campaigns.send",
"marketing.content_calendar",
"marketing.brand_kit",
"marketing.brand_ai_apply",
"marketing.seo",
"marketing.seo.template_fill",
"marketing.seo.ai_rewrite",
"marketing.reviews",
"integrations.ai",
"integrations.ai.byok",
"integrations.email",
"integrations.email.test",
"integrations.email.blast",
"billing.overview",
"billing.customer_portal",
"billing.quick_upgrade",
"billing.plans_compare",
"billing.checkout",
"settings.profile",
"settings.company",
"settings.alerts",
"settings.api_keys",
"settings.team",
"settings.team_invite",
"support.center",
"support.ticket_create",
"support.ticket_thread",
"capability.sku_cap",
"capability.ai_credits",
"capability.ai_processing",
"capability.eprel",
"capability.normalize_specs_fill",
"capability.campaign_ai",
"capability.email_live_send",
"capability.brand_ai_apply",
"capability.seo_ai_rewrite",
"capability.feed_source_limit",
"capability.export_feed_limit",
"capability.storage_limit",
"capability.api_access",
"capability.byok",
}
// FeatureSections are global section master-switch keys.
var FeatureSections = []string{
"shell",
"dashboard",
"catalog",
"feeds",
"stores",
"processing",
"marketing",
"integrations",
"billing",
"settings",
"support",
"capabilities",
}
var featureKeySection = map[string]string{
"shell.navigation": "shell",
"shell.command_palette": "shell",
"shell.company_switcher": "shell",
"shell.support_notifications": "shell",
"shell.tutorial": "shell",
"shell.account_menu": "shell",
"shell.billing_recovery_banner": "shell",
"dashboard.overview": "dashboard",
"dashboard.stats": "dashboard",
"dashboard.quick_links": "dashboard",
"dashboard.recent_jobs": "dashboard",
"dashboard.news_feed": "dashboard",
"dashboard.activation_checklist": "dashboard",
"dashboard.migrated_checklist": "dashboard",
"dashboard.etl_gaps": "dashboard",
"dashboard.store_reconnect": "dashboard",
"dashboard.upgrade_banners": "dashboard",
"catalog.products": "catalog",
"catalog.products.tab_processed": "catalog",
"catalog.products.tab_needs_review": "catalog",
"catalog.products.tab_error": "catalog",
"catalog.products.tab_processing": "catalog",
"catalog.products.tab_unprocessed": "catalog",
"catalog.products.process_categories": "catalog",
"catalog.products.process_attributes": "catalog",
"catalog.products.process_ai_titles": "catalog",
"catalog.products.process_ai_descriptions": "catalog",
"catalog.products.enrichment_review": "catalog",
"catalog.products.export_selection": "catalog",
"catalog.products.upgrade_prompt": "catalog",
"catalog.categories": "catalog",
"catalog.categories.title_formula": "catalog",
"catalog.categories.description_formula": "catalog",
"catalog.attributes": "catalog",
"catalog.attributes.bulk_import": "catalog",
"catalog.standard_fields": "catalog",
"catalog.standard_fields.groups": "catalog",
"catalog.structured_descriptions": "catalog",
"catalog.vector_categories": "catalog",
"feeds.list": "feeds",
"feeds.add_url": "feeds",
"feeds.add_csv": "feeds",
"feeds.sync": "feeds",
"feeds.mapping": "feeds",
"feeds.mapping.select_item": "feeds",
"feeds.mapping.map_fields": "feeds",
"feeds.export_feeds": "feeds",
"feeds.export_feeds.create": "feeds",
"feeds.export_feeds.generate": "feeds",
"feeds.uploads": "feeds",
"stores.hub": "stores",
"stores.woocommerce": "stores",
"stores.woocommerce.connection": "stores",
"stores.woocommerce.categories": "stores",
"stores.woocommerce.attributes": "stores",
"stores.woocommerce.orders": "stores",
"stores.woocommerce.reviews": "stores",
"stores.woocommerce.settings": "stores",
"stores.shopify": "stores",
"stores.shopify.connection": "stores",
"stores.shopify.orders": "stores",
"stores.shopify.settings": "stores",
"processing.monitor": "processing",
"marketing.campaigns": "marketing",
"marketing.campaigns.create": "marketing",
"marketing.campaigns.generate_ai": "marketing",
"marketing.campaigns.send": "marketing",
"marketing.content_calendar": "marketing",
"marketing.brand_kit": "marketing",
"marketing.brand_ai_apply": "marketing",
"marketing.seo": "marketing",
"marketing.seo.template_fill": "marketing",
"marketing.seo.ai_rewrite": "marketing",
"marketing.reviews": "marketing",
"integrations.ai": "integrations",
"integrations.ai.byok": "integrations",
"integrations.email": "integrations",
"integrations.email.test": "integrations",
"integrations.email.blast": "integrations",
"billing.overview": "billing",
"billing.customer_portal": "billing",
"billing.quick_upgrade": "billing",
"billing.plans_compare": "billing",
"billing.checkout": "billing",
"settings.profile": "settings",
"settings.company": "settings",
"settings.alerts": "settings",
"settings.api_keys": "settings",
"settings.team": "settings",
"settings.team_invite": "settings",
"support.center": "support",
"support.ticket_create": "support",
"support.ticket_thread": "support",
"capability.sku_cap": "capabilities",
"capability.ai_credits": "capabilities",
"capability.ai_processing": "capabilities",
"capability.eprel": "capabilities",
"capability.normalize_specs_fill": "capabilities",
"capability.campaign_ai": "capabilities",
"capability.email_live_send": "capabilities",
"capability.brand_ai_apply": "capabilities",
"capability.seo_ai_rewrite": "capabilities",
"capability.feed_source_limit": "capabilities",
"capability.export_feed_limit": "capabilities",
"capability.storage_limit": "capabilities",
"capability.api_access": "capabilities",
"capability.byok": "capabilities",
}
var featureCatalogSet = map[string]struct{}{}
func init() {
for _, k := range FeatureCatalogKeys {
featureCatalogSet[k] = struct{}{}
}
}
// SectionOfFeature returns the section for a registry feature key.
func SectionOfFeature(key string) (string, bool) {
s, ok := featureKeySection[key]
return s, ok
}
// IsKnownFeatureKey reports whether key is in the dashboard feature registry.
func IsKnownFeatureKey(key string) bool {
_, ok := featureCatalogSet[key]
return ok
}
// IsKnownFeatureSection reports whether section is a valid master-switch section.
func IsKnownFeatureSection(section string) bool {
for _, s := range FeatureSections {
if s == section {
return true
}
}
return false
}
func freePlanFeatureOff(key string) bool {
switch key {
case "capability.ai_processing":
return true
case "capability.api_access":
return true
case "capability.brand_ai_apply":
return true
case "capability.byok":
return true
case "capability.campaign_ai":
return true
case "capability.email_live_send":
return true
case "capability.seo_ai_rewrite":
return true
case "catalog.products.process_ai_descriptions":
return true
case "catalog.products.process_ai_titles":
return true
case "integrations.ai.byok":
return true
case "marketing.brand_ai_apply":
return true
case "marketing.campaigns.generate_ai":
return true
case "marketing.campaigns.send":
return true
case "marketing.seo.ai_rewrite":
return true
case "settings.api_keys":
return true
default:
return false
}
}
func starterPlanFeatureOff(key string) bool {
switch key {
case "capability.byok":
return true
case "integrations.ai.byok":
return true
default:
return false
}
}
@@ -0,0 +1,72 @@
package billing
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
type featureKeyDoc struct {
Key string `json:"key"`
}
// TestFeatureCatalogKeysMatchDocsJSON keeps Go FeatureCatalogKeys aligned with
// docs/plan-permissions/01-feature-keys.json (shared with the web catalog).
func TestFeatureCatalogKeysMatchDocsJSON(t *testing.T) {
t.Parallel()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", ".."))
path := filepath.Join(root, "docs", "plan-permissions", "01-feature-keys.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var docs []featureKeyDoc
if err := json.Unmarshal(raw, &docs); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
if len(docs) == 0 {
t.Fatal("docs feature keys empty")
}
want := make(map[string]struct{}, len(docs))
for _, row := range docs {
if row.Key == "" {
t.Fatal("empty key in docs JSON")
}
want[row.Key] = struct{}{}
}
got := make(map[string]struct{}, len(FeatureCatalogKeys))
for _, k := range FeatureCatalogKeys {
got[k] = struct{}{}
}
for k := range want {
if _, ok := got[k]; !ok {
t.Errorf("FeatureCatalogKeys missing docs key %q", k)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("FeatureCatalogKeys has extra key %q not in docs", k)
}
}
if len(got) != len(want) {
t.Fatalf("FeatureCatalogKeys len=%d docs len=%d", len(got), len(want))
}
}
func TestLegacyAllowlistIncludesStorageLimit(t *testing.T) {
t.Parallel()
// roles-matrix legacy_user / plan_profiles.legacy list storage_limit ON.
// Must not enable stores/marketing — only the marketing meter capability key.
if !LegacyFeatureAllowed("capability.storage_limit") {
t.Fatal("legacy allowlist must include capability.storage_limit (roles-matrix)")
}
if LegacyFeatureAllowed("stores.hub") || LegacyFeatureAllowed("marketing.campaigns") {
t.Fatal("legacy must still deny stores/marketing (no A1 pollution)")
}
}
@@ -0,0 +1,76 @@
package billing
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// FeatureKeyFromError extracts the feature key from an ErrFeatureDisabled wrap
// ("feature_disabled: marketing.campaigns.generate_ai").
func FeatureKeyFromError(err error) string {
if err == nil || !errors.Is(err, ErrFeatureDisabled) {
return ""
}
msg := err.Error()
const prefix = "feature_disabled:"
idx := strings.Index(strings.ToLower(msg), prefix)
if idx < 0 {
return ""
}
return strings.TrimSpace(msg[idx+len(prefix):])
}
// FeatureKeysForProcessingType maps a processing job type to registry keys that
// must be effective before StartJob may proceed.
func FeatureKeysForProcessingType(processingType string) []string {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "title":
return []string{"capability.ai_processing", "catalog.products.process_ai_titles"}
case "description":
return []string{"capability.ai_processing", "catalog.products.process_ai_descriptions"}
case "enhance", "enhance_only", "enhance-only", "seo", "seo_ai":
return []string{"capability.ai_processing"}
case "eprel", "eprel_only":
return []string{"capability.eprel"}
case "email_campaign", "email_campaign_ai", "campaign_ai":
return []string{"capability.campaign_ai", "marketing.campaigns.generate_ai"}
case "normalize", "specs", "fill", "categories", "attributes", "full", "":
return []string{"capability.normalize_specs_fill"}
default:
return []string{"capability.normalize_specs_fill"}
}
}
// AssertFeatures fails closed on the first disabled key.
// Loads Capabilities once for the whole key set (avoids N×CapabilitiesForCompany).
func (s *Service) AssertFeatures(ctx context.Context, companyID uuid.UUID, keys ...string) error {
if len(keys) == 0 {
return nil
}
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return err
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if caps.Features == nil || !caps.Features[key] {
return fmt.Errorf("%w: %s", ErrFeatureDisabled, key)
}
}
return nil
}
// AssertProcessingFeatures enforces plan ∩ global feature keys for a job type.
func (s *Service) AssertProcessingFeatures(ctx context.Context, companyID uuid.UUID, processingType string) error {
if s == nil {
return nil
}
return s.AssertFeatures(ctx, companyID, FeatureKeysForProcessingType(processingType)...)
}
@@ -0,0 +1,129 @@
package billing
import (
"errors"
"fmt"
"testing"
)
func TestDefaultPlanFeaturesFreeDeniesAI(t *testing.T) {
m := DefaultPlanFeatures("Free", false)
for _, key := range []string{
"capability.ai_processing",
"catalog.products.process_ai_titles",
"marketing.campaigns.generate_ai",
"settings.api_keys",
"capability.api_access",
"capability.email_live_send",
} {
if m[key] {
t.Fatalf("Free should deny %s", key)
}
}
if !m["catalog.products"] || !m["capability.normalize_specs_fill"] {
t.Fatal("Free should allow catalog + normalize")
}
}
func TestDefaultPlanFeaturesCustomEnableAll(t *testing.T) {
m := DefaultPlanFeatures("Acme Deal", true)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("custom should enable all; missing %s", k)
}
}
m2 := DefaultPlanFeatures("Enterprise", true)
for _, k := range FeatureCatalogKeys {
if !m2[k] {
t.Fatalf("Enterprise (is_custom) should enable all; missing %s", k)
}
}
}
func TestResolveEffectiveFeaturesGlobalSectionDisableAll(t *testing.T) {
gates := emptyGatesView()
gates.Sections["marketing"] = false
features, sections, disabled := ResolveEffectiveFeatures("Growth", false, nil, gates)
if sections["marketing"] {
t.Fatal("marketing section should be off")
}
if features["marketing.campaigns"] || features["marketing.campaigns.generate_ai"] {
t.Fatal("marketing keys must be false when section disabled")
}
found := false
for _, d := range disabled {
if d == "marketing.campaigns.generate_ai" {
found = true
break
}
}
if !found {
t.Fatal("disabled_features should list marketing.campaigns.generate_ai")
}
if !features["catalog.products"] {
t.Fatal("catalog should remain on")
}
}
func TestResolveEffectiveFeaturesCustomOverrideFalse(t *testing.T) {
gates := emptyGatesView()
overrides := map[string]bool{"settings.api_keys": false}
features, _, _ := ResolveEffectiveFeatures("Client Deal", true, overrides, gates)
if features["settings.api_keys"] {
t.Fatal("override false must win on custom")
}
if !features["capability.ai_processing"] {
t.Fatal("other keys stay on for custom")
}
}
func TestPlanAllowsFeatureCapabilityResolution(t *testing.T) {
if PlanAllowsFeature("Free", false, nil, "capability.ai_processing") {
t.Fatal("Free deny AI capability")
}
if !PlanAllowsFeature("Starter", false, nil, "capability.ai_processing") {
t.Fatal("Starter allow AI capability")
}
if PlanAllowsFeature("Starter", false, nil, "capability.byok") {
t.Fatal("Starter deny BYOK")
}
if !PlanAllowsFeature("Growth", false, nil, "capability.byok") {
t.Fatal("Growth allow BYOK")
}
}
func TestFeatureKeyFromError(t *testing.T) {
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
if got := FeatureKeyFromError(err); got != "marketing.campaigns.generate_ai" {
t.Fatalf("got %q", got)
}
if FeatureKeyFromError(errors.New("other")) != "" {
t.Fatal("non-feature error should yield empty")
}
}
func TestFeatureKeysForProcessingType(t *testing.T) {
keys := FeatureKeysForProcessingType("title")
if len(keys) != 2 || keys[0] != "capability.ai_processing" {
t.Fatalf("title keys: %v", keys)
}
keys = FeatureKeysForProcessingType("normalize")
if len(keys) != 1 || keys[0] != "capability.normalize_specs_fill" {
t.Fatalf("normalize keys: %v", keys)
}
}
func TestCloneGatesViewIndependent(t *testing.T) {
src := emptyGatesView()
src.Sections["marketing"] = false
src.Features["capability.ai_processing"] = false
dst := cloneGatesView(src)
dst.Sections["marketing"] = true
dst.Features["capability.ai_processing"] = true
if src.Sections["marketing"] {
t.Fatal("clone must not share sections map")
}
if src.Features["capability.ai_processing"] {
t.Fatal("clone must not share features map")
}
}
+135
View File
@@ -0,0 +1,135 @@
package billing
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// FeatureDef is one catalog entry for listFeatures / admin editors.
type FeatureDef struct {
Key string `json:"key"`
Section string `json:"section"`
Label string `json:"label,omitempty"`
}
// ListFeatures returns the canonical feature registry (listFeatures).
func (s *Service) ListFeatures(_ context.Context) ([]FeatureDef, error) {
out := make([]FeatureDef, 0, len(FeatureCatalogKeys))
for _, key := range FeatureCatalogKeys {
section, _ := SectionOfFeature(key)
out = append(out, FeatureDef{
Key: key,
Section: section,
Label: key,
})
}
return out, nil
}
// IsAllowed reports effective(feature) for a company's active plan (isAllowed).
func (s *Service) IsAllowed(ctx context.Context, companyID uuid.UUID, key string) (bool, error) {
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return false, err
}
key = strings.TrimSpace(key)
if caps.Features == nil {
return false, nil
}
return caps.Features[key], nil
}
// IsAllowedForPlan reports effective(feature) for a plan id (globals still apply).
func (s *Service) IsAllowedForPlan(ctx context.Context, planID int64, key string) (bool, error) {
name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return false, err
}
gates, err := s.GetFeatureGates(ctx)
if err != nil {
return false, err
}
features, _, _ := ResolveEffectiveFeaturesEx(name, isCustom, isLegacy, overrides, gates)
return features[strings.TrimSpace(key)], nil
}
// SetPlanFeature merges one override into plans.features (setPlanFeature).
func (s *Service) SetPlanFeature(ctx context.Context, planID int64, key string, enabled bool) error {
key = strings.TrimSpace(key)
if !IsKnownFeatureKey(key) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
}
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
if overrides == nil {
overrides = map[string]bool{}
}
overrides[key] = enabled
_, err = s.SetPlanFeatures(ctx, planID, overrides)
return err
}
// SetGlobalFeature upserts one platform master switch (setGlobalFeature).
// Section ids use kind=section; feature keys use kind=feature.
func (s *Service) SetGlobalFeature(ctx context.Context, gateKey string, enabled bool, updatedBy *uuid.UUID) error {
gateKey = strings.TrimSpace(gateKey)
if gateKey == "" {
return fmt.Errorf("%w: empty gate key", ErrUnknownFeatureKey)
}
if IsKnownFeatureSection(gateKey) {
_, err := s.SetSectionGate(ctx, gateKey, enabled, updatedBy)
return err
}
if !IsKnownFeatureKey(gateKey) {
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, gateKey)
}
_, err := s.SetFeatureGates(ctx, nil, map[string]bool{gateKey: enabled}, updatedBy)
return err
}
// EnableAllForPlan writes all registry keys as true overrides (enableAllForPlan).
func (s *Service) EnableAllForPlan(ctx context.Context, planID int64) error {
_, err := s.EnableAllPlanFeatures(ctx, planID)
return err
}
// ApplyDefaultMatrix replaces plans.features with sparse defaults for that plan (applyDefaultMatrix).
// Custom packages get an empty override map (is_custom => all ON at resolve).
// Legacy packages get SparseLegacyOverrides (processing.monitor OFF; image-nav ON).
func (s *Service) ApplyDefaultMatrix(ctx context.Context, planID int64) error {
name, isCustom, isLegacy, _, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
_, err = s.SetPlanFeatures(ctx, planID, SparseDefaultOverridesEx(name, isCustom, isLegacy))
return err
}
// AssertFeature fails closed when a feature is not effective for the company.
func (s *Service) AssertFeature(ctx context.Context, companyID uuid.UUID, key string) error {
ok, err := s.IsAllowed(ctx, companyID, key)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%w: %s", ErrFeatureDisabled, strings.TrimSpace(key))
}
return nil
}
// ResolveFeatures is the contract name for ResolveEffectiveFeatures (plan ∧ globals).
func ResolveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
features, _, _ := ResolveEffectiveFeatures(planName, isCustom, overrides, gates)
return features
}
// ResolveFeaturesEx includes an explicit is_legacy flag.
func ResolveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
features, _, _ := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates)
return features
}
@@ -0,0 +1,90 @@
package billing
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func TestDefaultPlanFeaturesStarterBYOK(t *testing.T) {
m := DefaultPlanFeatures("Starter", false)
if !m["catalog.products.process_ai_titles"] {
t.Fatal("Starter should allow AI titles")
}
if m["integrations.ai.byok"] || m["capability.byok"] {
t.Fatal("Starter should deny BYOK")
}
}
func TestDefaultPlanFeaturesGrowthAllOn(t *testing.T) {
m := DefaultPlanFeatures("Growth", false)
for _, k := range FeatureCatalogKeys {
if !m[k] {
t.Fatalf("Growth should allow %s", k)
}
}
}
func TestPlanAllowsOverrideFalseWins(t *testing.T) {
overrides := map[string]bool{"settings.api_keys": false}
if PlanAllowsFeature("Growth", false, overrides, "settings.api_keys") {
t.Fatal("override false should win on Growth")
}
}
func TestResolveFeaturesGlobalFeatureOff(t *testing.T) {
gates := FeatureGatesView{
Sections: map[string]bool{},
Features: map[string]bool{"capability.byok": false},
}
features := ResolveFeatures("Business", false, nil, gates)
if features["capability.byok"] {
t.Fatal("global feature kill-switch should win")
}
}
func TestSparseDefaultOverridesFree(t *testing.T) {
sparse := SparseDefaultOverrides("Free", false)
if sparse["catalog.products.process_ai_titles"] != false {
t.Fatal("expected sparse false for AI titles")
}
if _, ok := sparse["catalog.products"]; ok {
t.Fatal("ON keys should not appear in sparse overrides")
}
if len(SparseDefaultOverrides("Acme", true)) != 0 {
t.Fatal("custom sparse should be empty")
}
}
func TestValidateFeatureOverridesRejectsUnknown(t *testing.T) {
err := validateFeatureOverrides(map[string]bool{"not.a.real.key": true})
if !errors.Is(err, ErrUnknownFeatureKey) {
t.Fatalf("want ErrUnknownFeatureKey, got %v", err)
}
}
func TestListFeaturesCatalogComplete(t *testing.T) {
s := &Service{}
list, err := s.ListFeatures(context.Background())
if err != nil {
t.Fatal(err)
}
if len(list) != len(FeatureCatalogKeys) {
t.Fatalf("got %d want %d", len(list), len(FeatureCatalogKeys))
}
if list[0].Section == "" {
t.Fatal("section required")
}
}
func TestAssertFeatureErrorWraps(t *testing.T) {
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
if !errors.Is(err, ErrFeatureDisabled) {
t.Fatal(err)
}
if !strings.Contains(err.Error(), "marketing.campaigns.generate_ai") {
t.Fatal(err)
}
}
+172
View File
@@ -0,0 +1,172 @@
package billing
import (
"errors"
"fmt"
"strings"
"testing"
)
func TestGateErrorWrapping(t *testing.T) {
creditErr := fmt.Errorf("%w: need at least %d credits (have %d)", ErrInsufficientCredits, 5, 2)
if !errors.Is(creditErr, ErrInsufficientCredits) {
t.Fatal("expected ErrInsufficientCredits")
}
if !strings.Contains(creditErr.Error(), "need at least 5") {
t.Fatalf("unexpected message: %v", creditErr)
}
limitErr := fmt.Errorf("%w: plan allows up to %d products", ErrProductLimitExceeded, 100)
if !errors.Is(limitErr, ErrProductLimitExceeded) {
t.Fatal("expected ErrProductLimitExceeded")
}
if errors.Is(limitErr, ErrInsufficientCredits) {
t.Fatal("should not match credits error")
}
aiErr := fmt.Errorf("%w — upgrade", ErrAIRequiresUpgrade)
if !errors.Is(aiErr, ErrAIRequiresUpgrade) {
t.Fatal("expected ErrAIRequiresUpgrade")
}
}
func TestComputeEntitlements(t *testing.T) {
free := ComputeEntitlements("Free", 0, 0, false)
if free.CanUseAI || !free.CanUseEPREL || !free.IsFreePlan {
t.Fatalf("free: %+v", free)
}
freeWithLeftover := ComputeEntitlements("Free", 0, 10, false)
if !freeWithLeftover.CanUseAI {
t.Fatal("leftover credits on Free should unlock AI")
}
growth := ComputeEntitlements("Growth", 2000, 0, false)
if !growth.CanUseAI || !growth.CanUseEPREL || !growth.IsPaidPlan {
t.Fatalf("growth: %+v", growth)
}
enterprise := ComputeEntitlements("Enterprise", EnterpriseUnlimitedCredits, EnterpriseUnlimitedCredits, false)
if !enterprise.CanUseAI || !enterprise.CanUseEPREL || !enterprise.IsPaidPlan || enterprise.IsFreePlan {
t.Fatalf("enterprise: %+v", enterprise)
}
if enterprise.MonthlyCredits != EnterpriseUnlimitedCredits || enterprise.RemainingCredits != EnterpriseUnlimitedCredits {
t.Fatalf("enterprise credits: %+v", enterprise)
}
if ProcessingTypeRequiresAI("title") != true {
t.Fatal("title requires AI")
}
if ProcessingTypeRequiresAI("full") {
t.Fatal("full should auto-skip AI on Free, not hard-require")
}
if !ProcessingTypeRequiresEPREL("eprel_only") {
t.Fatal("eprel_only requires EPREL")
}
}
func TestDefaultPublicPlansEnterpriseUnlimited(t *testing.T) {
plans := defaultPublicPlans()
var ent *Plan
for i := range plans {
if strings.EqualFold(plans[i].Name, "Enterprise") {
ent = &plans[i]
break
}
}
if ent == nil {
t.Fatal("Enterprise missing from defaultPublicPlans")
}
if ent.MonthlyCredits != EnterpriseUnlimitedCredits {
t.Fatalf("monthly_credits=%d want %d", ent.MonthlyCredits, EnterpriseUnlimitedCredits)
}
if ent.MaxProducts != nil {
t.Fatalf("max_products should be nil (unlimited), got %v", *ent.MaxProducts)
}
if !ent.IsCustom {
t.Fatal("Enterprise should be is_custom")
}
}
func TestDefaultPublicPlansFreeZeroCredits(t *testing.T) {
plans := defaultPublicPlans()
var free *Plan
for i := range plans {
if strings.EqualFold(plans[i].Name, "Free") {
free = &plans[i]
break
}
}
if free == nil {
t.Fatal("Free missing from defaultPublicPlans")
}
if free.MonthlyCredits != 0 {
t.Fatalf("Free monthly_credits=%d want 0 (Enterprise seed must not change this)", free.MonthlyCredits)
}
if free.MaxProducts == nil || *free.MaxProducts != 50 {
t.Fatalf("Free max_products=%v want 50", free.MaxProducts)
}
if free.IsCustom {
t.Fatal("Free must not be is_custom")
}
// Enterprise packaging must not leak into Free.
if free.MonthlyCredits == EnterpriseUnlimitedCredits {
t.Fatal("Free must not share Enterprise credit pack")
}
}
func TestDefaultPublicPlansFiftyPercentCover(t *testing.T) {
want := map[string]int{
"Starter": 100,
"Plus": 400,
"Growth": 1_200,
"Business": 4_000,
"Scale": 12_000,
}
pctWant := map[string]int{
"Starter": 50, "Plus": 50, "Growth": 50, "Business": 50, "Scale": 50, "Enterprise": 50,
}
maxWant := map[string]int{
"Free": 50, "Starter": 100, "Plus": 400, "Growth": 1_200, "Business": 4_000, "Scale": ScaleMaxProducts,
}
for name, pct := range pctWant {
if got := PlanAICoverPercent(name); got != pct {
t.Fatalf("PlanAICoverPercent(%s)=%d want %d", name, got, pct)
}
}
for name, exp := range want {
if got := MonthlyCreditsForPlan(name, 0); got != exp {
t.Fatalf("MonthlyCreditsForPlan(%s)=%d want %d", name, got, exp)
}
}
for _, p := range defaultPublicPlans() {
if exp, ok := want[p.Name]; ok && p.MonthlyCredits != exp {
t.Fatalf("%s monthly_credits=%d want %d", p.Name, p.MonthlyCredits, exp)
}
if p.Name == "Enterprise" {
if p.MaxProducts != nil {
t.Fatalf("Enterprise max_products should be nil, got %v", p.MaxProducts)
}
continue
}
wantMax, ok := maxWant[p.Name]
if !ok {
t.Fatalf("%s missing from maxWant", p.Name)
}
if p.MaxProducts == nil || *p.MaxProducts != wantMax {
t.Fatalf("%s max_products=%v want %d", p.Name, p.MaxProducts, wantMax)
}
if p.Name != "Free" {
base := CreditSKUBase(p.Name)
if base <= 0 || base > wantMax {
t.Fatalf("%s CreditSKUBase=%d must be in (0, MaxProducts=%d]", p.Name, base, wantMax)
}
}
}
// Starter included AI must stay tiny vs A1 (~€300) economics.
if want["Starter"] > 150 {
t.Fatalf("Starter monthly credits=%d too high vs A1 positioning", want["Starter"])
}
if CreditSKUBase("Starter") != 100 || want["Starter"] != 100 {
t.Fatalf("Starter base/credits: base=%d credits=%d", CreditSKUBase("Starter"), want["Starter"])
}
if got := PlanMaxProducts("Scale"); got == nil || *got != ScaleMaxProducts || ScaleMaxProducts >= 1_000_000 {
t.Fatalf("Scale max_products=%v ScaleMaxProducts=%d want %d (<1M)", got, ScaleMaxProducts, ScaleMaxProducts)
}
}
+168
View File
@@ -0,0 +1,168 @@
package billing
import (
"strings"
)
// A1LegacyCompanyID is the MySQL company_id for A1 Slovenija (migrated dump name kept in PG).
// Cohort remains legacy even if an older local rename used "Local Demo Co".
const A1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
// LegacyPlanName is the seeded package name for migrated / limited-nav tenants.
const LegacyPlanName = "Legacy"
// PlanProfile is the packaging bucket used for default feature matrices.
type PlanProfile string
const (
PlanProfileFree PlanProfile = "free"
PlanProfileStarter PlanProfile = "starter"
PlanProfileGrowth PlanProfile = "growth"
PlanProfileBusiness PlanProfile = "business"
PlanProfileEnterprise PlanProfile = "enterprise"
PlanProfileLegacy PlanProfile = "legacy"
PlanProfileCustom PlanProfile = "custom"
)
// legacyFeatureAllowlist is the ON set for the legacy (A1) matrix.
// Source: docs/admin-roles-support/03-roles-matrix.md / .json (legacy_user).
var legacyFeatureAllowlist = map[string]struct{}{
"shell.navigation": {},
"shell.command_palette": {},
"shell.company_switcher": {},
"shell.tutorial": {},
"shell.account_menu": {},
"shell.billing_recovery_banner": {},
"dashboard.overview": {},
"dashboard.stats": {},
"dashboard.quick_links": {},
"dashboard.recent_jobs": {},
"dashboard.news_feed": {},
"dashboard.activation_checklist": {},
"dashboard.migrated_checklist": {},
"dashboard.etl_gaps": {},
"dashboard.upgrade_banners": {},
"catalog.products": {},
"catalog.products.tab_processed": {},
"catalog.products.tab_needs_review": {},
"catalog.products.tab_error": {},
"catalog.products.tab_processing": {},
"catalog.products.tab_unprocessed": {},
"catalog.products.process_categories": {},
"catalog.products.process_attributes": {},
"catalog.products.process_ai_titles": {},
"catalog.products.process_ai_descriptions": {},
"catalog.products.enrichment_review": {},
"catalog.products.export_selection": {},
"catalog.products.upgrade_prompt": {},
"catalog.categories": {},
"catalog.categories.title_formula": {},
"catalog.categories.description_formula": {},
"catalog.attributes": {},
"catalog.attributes.bulk_import": {},
"catalog.standard_fields": {},
"catalog.standard_fields.groups": {},
"feeds.list": {},
"feeds.add_url": {},
"feeds.add_csv": {},
"feeds.sync": {},
"feeds.mapping": {},
"feeds.mapping.select_item": {},
"feeds.mapping.map_fields": {},
"feeds.export_feeds": {},
"feeds.export_feeds.create": {},
"feeds.export_feeds.generate": {},
"feeds.uploads": {},
"billing.overview": {},
"billing.customer_portal": {},
"billing.quick_upgrade": {},
"billing.plans_compare": {},
"billing.checkout": {},
"settings.profile": {},
"settings.company": {},
"settings.alerts": {},
"settings.api_keys": {},
"settings.team": {},
"settings.team_invite": {},
"capability.sku_cap": {},
"capability.ai_credits": {},
"capability.ai_processing": {},
"capability.eprel": {},
"capability.normalize_specs_fill": {},
"capability.feed_source_limit": {},
"capability.export_feed_limit": {},
"capability.storage_limit": {},
"capability.api_access": {},
}
// IsLegacyPlanName reports whether a plan name matches the legacy cohort patterns
// (exact "legacy", A1*, or "a1 slovenija"). See docs/admin-roles-support/03-roles-matrix.md.
func IsLegacyPlanName(planName string) bool {
n := strings.ToLower(strings.TrimSpace(planName))
if n == "" {
return false
}
if n == "legacy" {
return true
}
if strings.Contains(n, "a1 slovenija") {
return true
}
if n == "a1" || strings.HasPrefix(n, "a1 ") || strings.HasPrefix(n, "a1-") || strings.HasPrefix(n, "a1_") {
return true
}
return false
}
// IsLegacyPlan reports legacy packaging from an explicit flag and/or name patterns.
func IsLegacyPlan(planName string, isLegacyFlag bool) bool {
return isLegacyFlag || IsLegacyPlanName(planName)
}
// IsLegacyCompanyID reports whether a remapped legacy MySQL company id is the A1 cohort.
func IsLegacyCompanyID(legacyCompanyID string) bool {
return strings.EqualFold(strings.TrimSpace(legacyCompanyID), A1LegacyCompanyID)
}
// IsA1CohortCompany reports whether a company is the migrated A1 tenant.
// Match only immutable legacy_company_id — never mutable display names
// (register/rename to "A1" must not grant Legacy plan privileges).
// companyName is retained for call-site compatibility; it is ignored.
func IsA1CohortCompany(legacyCompanyID, companyName string) bool {
_ = companyName
return IsLegacyCompanyID(legacyCompanyID)
}
// LegacyFeatureAllowed reports whether key is ON in the legacy matrix.
func LegacyFeatureAllowed(key string) bool {
_, ok := legacyFeatureAllowlist[key]
return ok
}
// ResolvePlanProfile maps name + flags to the default matrix bucket.
func ResolvePlanProfile(planName string, isCustom, isLegacyFlag bool) PlanProfile {
if IsCustomPackage(planName, isCustom) {
norm := strings.ToLower(strings.TrimSpace(planName))
if norm == "enterprise" {
return PlanProfileEnterprise
}
return PlanProfileCustom
}
if IsLegacyPlan(planName, isLegacyFlag) {
return PlanProfileLegacy
}
norm := strings.ToLower(strings.TrimSpace(planName))
switch norm {
case "", "free":
return PlanProfileFree
case "starter", "plus":
return PlanProfileStarter
case "growth":
return PlanProfileGrowth
case "business", "scale":
return PlanProfileBusiness
case "enterprise":
return PlanProfileEnterprise
}
return PlanProfileFree
}
@@ -0,0 +1,18 @@
package billing
// SparseLegacyOverrides returns false overrides for every registry key not on the legacy allow-list.
// Storing these makes admin UIs show an explicit legacy matrix; resolve also applies DefaultPlanFeatures.
func SparseLegacyOverrides() map[string]bool {
out := make(map[string]bool)
for _, k := range FeatureCatalogKeys {
if !LegacyFeatureAllowed(k) {
out[k] = false
}
}
return out
}
// featuresMapEmpty reports whether the sparse override map is unset (nil or no keys).
func featuresMapEmpty(features map[string]bool) bool {
return len(features) == 0
}
@@ -0,0 +1,357 @@
package billing
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureLegacyDefaults idempotently:
// 1. Upserts the Legacy plan row (meters aligned with Enterprise for migrated catalogs)
// 2. Repairs prior enable-all feature maps on legacy-named plans
// 3. Delegates empty/flagged sparse backfill to EnsureLegacyPlanFeatureSeeds
// 4. Assigns the Legacy plan to A1 cohort companies when missing or on a non-legacy profile
// 5. Repairs dump-faithful A1 PAYG plan rows (is_custom, clear mistaken is_legacy)
func (s *Service) EnsureLegacyDefaults(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
if err := s.ensureLegacyPlanRow(ctx); err != nil {
return err
}
if err := s.repairLegacyEnableAllFeatures(ctx); err != nil {
return err
}
if err := s.EnsureLegacyPlanFeatureSeeds(ctx); err != nil {
return err
}
if err := s.assignLegacyPlanToA1Companies(ctx); err != nil {
return err
}
return s.ensureA1PaygPlanSemantics(ctx)
}
// repairLegacyEnableAllFeatures rewrites full all-true maps on legacy-named plans
// (left over from prior custom enable-all create) to SparseLegacyOverrides.
func (s *Service) repairLegacyEnableAllFeatures(ctx context.Context) error {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
FROM plans`)
if err != nil {
if isUndefinedColumn(err) {
rows, err = s.Pool.Query(ctx, `
SELECT id, name, false, COALESCE(features, '{}'::jsonb) FROM plans`)
}
if err != nil {
if isUndefinedRelation(err) || isUndefinedColumn(err) {
return nil
}
return err
}
}
defer rows.Close()
for rows.Next() {
var id int64
var name string
var isLegacy bool
var raw []byte
if err := rows.Scan(&id, &name, &isLegacy, &raw); err != nil {
return err
}
// Only the explicit Legacy package is rewritten; A1 PAYG / other A1* deals keep features.
if !strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
continue
}
if !IsLegacyPlan(name, isLegacy) {
continue
}
overrides, err := decodeFeaturesJSON(raw)
if err != nil {
return err
}
if featuresMapEmpty(overrides) || !isEnableAllOverrides(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, SparseLegacyOverrides()); err != nil {
return err
}
}
return rows.Err()
}
func (s *Service) ensureLegacyPlanRow(ctx context.Context) error {
desc := "Migrated legacy package — catalog, feeds, billing & settings (no Background Tasks / stores / marketing)"
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, LegacyPlanName).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
_, err = s.Pool.Exec(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, is_legacy, term)
VALUES ($1, $2, $3, NULL, NULL, false, true, 'monthly')`,
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1, $2, $3, NULL, NULL, false, 'monthly')`,
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
}
if err != nil {
return err
}
}
return s.seedLegacyFeaturesIfEmpty(ctx, 0, LegacyPlanName)
}
if err != nil {
return err
}
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
is_custom = false, is_legacy = true, term = 'monthly', updated_at = now()
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
is_custom = false, term = 'monthly', updated_at = now()
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
}
if err != nil {
return err
}
}
return s.seedLegacyFeaturesIfEmpty(ctx, id, LegacyPlanName)
}
func isEnableAllOverrides(overrides map[string]bool) bool {
if len(overrides) < len(FeatureCatalogKeys) {
return false
}
for _, k := range FeatureCatalogKeys {
v, ok := overrides[k]
if !ok || !v {
return false
}
}
return true
}
func shouldWriteLegacySparse(overrides map[string]bool) bool {
return featuresMapEmpty(overrides) || isEnableAllOverrides(overrides)
}
func (s *Service) seedLegacyFeaturesIfEmpty(ctx context.Context, planID int64, name string) error {
if planID == 0 {
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
if err != nil {
return err
}
planID = id
}
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
if err != nil {
return err
}
if !shouldWriteLegacySparse(overrides) {
return nil
}
_, err = s.SetPlanFeatures(ctx, planID, SparseLegacyOverrides())
return err
}
func (s *Service) assignLegacyPlanToA1Companies(ctx context.Context) error {
legacyID, err := s.PlanIDByName(ctx, LegacyPlanName)
if err != nil {
return err
}
// One row per company using the active plan only — joining all company_plans rows
// previously re-AssignPlan'd when an inactive Enterprise row appeared and wiped
// migrated credit_balances (A1 dump 2500/216 → fake pack).
// Privilege-sensitive: match ONLY immutable legacy_company_id. Mutable names
// ("A1", "Local Demo Co", …) must never auto-AssignPlan (register/rename IDOR).
rows, err := s.Pool.Query(ctx, `
SELECT c.id::text, COALESCE(c.legacy_company_id, ''), COALESCE(c.name, ''),
COALESCE(p.name, ''), COALESCE(p.is_legacy, false),
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0)
FROM companies c
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
LEFT JOIN plans p ON p.id = cp.plan_id
LEFT JOIN credit_balances cb ON cb.company_id = c.id
WHERE lower(COALESCE(c.legacy_company_id, '')) = lower($1)`,
A1LegacyCompanyID)
if err != nil {
// Fail closed when legacy_company_id is unavailable — never fall back to name match.
if isUndefinedColumn(err) {
return nil
}
return err
}
defer rows.Close()
for rows.Next() {
var (
cid, legacyCID, cname, planName string
planLegacy bool
total, used int
)
if err := rows.Scan(&cid, &legacyCID, &cname, &planName, &planLegacy, &total, &used); err != nil {
return err
}
if !IsA1CohortCompany(legacyCID, cname) {
continue
}
if IsLegacyPlan(planName, planLegacy) {
continue
}
// Dump-faithful A1 PAYG / other custom deals keep their plan + wallet.
if strings.EqualFold(strings.TrimSpace(planName), "A1") || strings.Contains(strings.ToLower(planName), "a1") {
continue
}
// Preserve migrated wallets — AssignPlan resets used_credits and total from plan monthly.
if total > 0 || used > 0 {
continue
}
companyUUID, err := uuid.Parse(cid)
if err != nil {
continue
}
if err := s.AssignPlan(ctx, companyUUID, legacyID, false, 0); err != nil {
return err
}
}
return rows.Err()
}
// ensureA1PaygPlanSemantics repairs dump-faithful A1 plans:
// is_custom=true, is_legacy=false, PAYG description, A1PaygPlanFeatures
// (Stores + Marketing + Integrations OFF), and documents open-ended contract dates on
// company_plans.notes when dates are null.
func (s *Service) ensureA1PaygPlanSemantics(ctx context.Context) error {
desc := "A1 pay-as-you-go — credits wallet, unlimited SKUs, catalog/feeds/processing/billing (Stores, Marketing, Integrations off). EPREL included on all plans."
_, err := s.Pool.Exec(ctx, `
UPDATE plans SET
is_custom = true,
is_legacy = false,
description = $1,
updated_at = now()
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`, desc)
if err != nil {
if isUndefinedColumn(err) {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET is_custom = true, description = $1, updated_at = now()
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`, desc)
}
if err != nil {
return err
}
}
rows, err := s.Pool.Query(ctx, `
SELECT id, name, COALESCE(features, '{}'::jsonb)
FROM plans
WHERE lower(name) = 'a1'
OR lower(name) LIKE 'a1 %'
OR lower(name) LIKE 'a1-%'
OR lower(name) LIKE 'a1_%'
OR lower(name) LIKE '%a1 slovenija%'`)
if err != nil {
if isUndefinedRelation(err) || isUndefinedColumn(err) {
return nil
}
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var name string
var raw []byte
if err := rows.Scan(&id, &name, &raw); err != nil {
return err
}
overrides, err := decodeFeaturesJSON(raw)
if err != nil {
return err
}
if !shouldWriteA1PaygFeatures(overrides) {
continue
}
if _, err := s.SetPlanFeatures(ctx, id, A1PaygPlanFeatures()); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
const paygNote = "PAYG: open-ended contract (no end date). Credits are consumed as used; yearly packaging is advisory."
// Privilege-sensitive notes: match A1* plan names and/or immutable legacy_company_id only.
// Never match mutable company display names (same isolation as assignLegacyPlanToA1Companies).
_, err = s.Pool.Exec(ctx, `
UPDATE company_plans cp
SET notes = CASE
WHEN COALESCE(cp.notes, '') = '' THEN $1
WHEN cp.notes LIKE '%' || $1 || '%' THEN cp.notes
ELSE cp.notes || E'\n' || $1
END,
updated_at = now()
FROM plans p, companies c
WHERE cp.plan_id = p.id
AND cp.company_id = c.id
AND cp.is_active = true
AND cp.contract_end_date IS NULL
AND (
lower(p.name) = 'a1'
OR lower(p.name) LIKE 'a1 %'
OR lower(p.name) LIKE 'a1-%'
OR lower(p.name) LIKE 'a1_%'
OR lower(p.name) LIKE '%a1 slovenija%'
OR lower(COALESCE(c.legacy_company_id, '')) = lower($2)
)`, paygNote, A1LegacyCompanyID)
if err != nil && !isUndefinedColumn(err) && !isUndefinedRelation(err) {
return err
}
return nil
}
func looksLikeLegacySparse(overrides map[string]bool) bool {
if len(overrides) == 0 {
return false
}
for _, v := range overrides {
if v {
return false
}
}
return true
}
// shouldWriteA1PaygFeatures reports whether A1 plan features need hygiene to the
// tailored PAYG matrix (Stores + Marketing + Integrations explicitly OFF).
func shouldWriteA1PaygFeatures(overrides map[string]bool) bool {
if featuresMapEmpty(overrides) || looksLikeLegacySparse(overrides) || isEnableAllOverrides(overrides) {
return true
}
for _, k := range FeatureCatalogKeys {
if !A1PaygFeatureDenied(k) {
continue
}
v, ok := overrides[k]
if !ok || v {
return true
}
}
return false
}
@@ -0,0 +1,146 @@
package billing
import "testing"
func TestIsLegacyPlanName(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"Legacy": true,
"legacy": true,
"A1": true,
"A1 Slovenija": true,
"a1-deal": true,
"A1 Deal": true,
"My Legacy Co": false, // exact "legacy" only — substring must not match
"Free": false,
"Enterprise": false,
"Merkur": false,
"": false,
}
for in, want := range cases {
if got := IsLegacyPlanName(in); got != want {
t.Fatalf("IsLegacyPlanName(%q)=%v want %v", in, got, want)
}
}
}
func TestDefaultPlanFeaturesLegacyMatrix(t *testing.T) {
t.Parallel()
for _, name := range []string{"A1", "Legacy", "A1 Slovenija"} {
m := DefaultPlanFeatures(name, false)
if len(m) != len(FeatureCatalogKeys) {
t.Fatalf("%s size=%d want %d", name, len(m), len(FeatureCatalogKeys))
}
for _, k := range []string{
"dashboard.overview",
"catalog.products",
"feeds.list",
"feeds.export_feeds",
"catalog.categories",
"catalog.attributes",
"catalog.standard_fields",
"billing.overview",
"settings.profile",
"capability.ai_processing",
"catalog.products.process_ai_titles",
"capability.storage_limit",
"dashboard.migrated_checklist",
"dashboard.etl_gaps",
} {
if !m[k] {
t.Fatalf("%s should allow %s", name, k)
}
}
for _, k := range []string{
"processing.monitor",
"stores.hub",
"marketing.campaigns",
"integrations.ai",
"support.center",
"shell.support_notifications",
"capability.byok",
} {
if m[k] {
t.Fatalf("%s should deny %s", name, k)
}
}
}
// Explicit Legacy package (or is_legacy on non-custom) forces legacy matrix.
flagged := DefaultPlanFeaturesEx("Legacy", false, true)
if flagged["processing.monitor"] {
t.Fatal("is_legacy flag must apply legacy matrix when not custom")
}
// is_custom wins over is_legacy for PAYG core, but Stores/Marketing/Integrations stay denied.
customWins := DefaultPlanFeaturesEx("A1", true, true)
if !customWins["processing.monitor"] {
t.Fatal("is_custom must win over is_legacy for PAYG core features")
}
if customWins["stores.hub"] || customWins["marketing.campaigns"] || customWins["integrations.ai"] || customWins["integrations.email"] {
t.Fatal("A1 PAYG must still deny Stores, Marketing, and Integrations")
}
if customWins["dashboard.etl_gaps"] || customWins["dashboard.store_reconnect"] || customWins["dashboard.migrated_checklist"] {
t.Fatal("A1 PAYG must deny cutover honesty chrome (ETL gaps / reconnect / migrated checklist)")
}
}
func TestResolvePlanProfile(t *testing.T) {
t.Parallel()
if ResolvePlanProfile("A1", false, false) != PlanProfileLegacy {
t.Fatal("A1 without is_custom → legacy")
}
if ResolvePlanProfile("A1", true, false) != PlanProfileCustom {
t.Fatal("A1 is_custom PAYG → custom")
}
if ResolvePlanProfile("A1", true, true) != PlanProfileCustom {
t.Fatal("A1 is_custom wins over is_legacy flag")
}
if ResolvePlanProfile("Free", false, false) != PlanProfileFree {
t.Fatal("Free → free")
}
if ResolvePlanProfile("Growth", false, false) != PlanProfileGrowth {
t.Fatal("Growth → growth")
}
if ResolvePlanProfile("Enterprise", true, false) != PlanProfileEnterprise {
t.Fatal("Enterprise → enterprise")
}
if ResolvePlanProfile("Merkur", false, false) != PlanProfileCustom {
t.Fatal("Merkur → custom")
}
}
func TestIsLegacyCompanyID(t *testing.T) {
t.Parallel()
if !IsLegacyCompanyID(A1LegacyCompanyID) {
t.Fatal("A1 id should match")
}
if IsLegacyCompanyID("other") {
t.Fatal("other id should not match")
}
}
func TestIsA1CohortCompany(t *testing.T) {
t.Parallel()
if !IsA1CohortCompany(A1LegacyCompanyID, "Anything") {
t.Fatal("legacy id must match")
}
// Mutable display names must never grant cohort privileges (register/rename).
for _, name := range []string{"A1 Slovenija", "Local Demo Co", "A1", "a1", "Retail A1", "Baikal"} {
if IsA1CohortCompany("", name) {
t.Fatalf("name-only %q must not match", name)
}
}
}
func TestShouldWriteLegacySparse(t *testing.T) {
t.Parallel()
if !shouldWriteLegacySparse(nil) || !shouldWriteLegacySparse(map[string]bool{}) {
t.Fatal("empty should write")
}
if !shouldWriteLegacySparse(AllRegistryFeatures(true)) {
t.Fatal("enable-all should repair")
}
partial := map[string]bool{"catalog.products": false}
if shouldWriteLegacySparse(partial) {
t.Fatal("admin partial customization must not be wiped")
}
}
+126
View File
@@ -0,0 +1,126 @@
package billing
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// CompanyWithoutActivePlan is a tenant with no is_active company_plans row.
type CompanyWithoutActivePlan struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
LegacyCompanyID string `json:"legacy_company_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PlanIDByName resolves a plan by case-insensitive name (lowest id wins).
func (s *Service) PlanIDByName(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, ErrPlanNameRequired
}
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPlanNotFound
}
if err != nil {
return 0, err
}
return id, nil
}
// HasActivePlan reports whether the company has an is_active company_plans row.
func (s *Service) HasActivePlan(ctx context.Context, companyID uuid.UUID) (bool, error) {
var has bool
err := s.Pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM company_plans WHERE company_id = $1 AND is_active = true
)`, companyID).Scan(&has)
return has, err
}
// ListCompaniesWithoutActivePlan returns companies with no active plan assignment.
// Safe read-only operator / cutover helper (never mutates).
// Excludes the A1 cohort (legacy_company_id) — A1 plans are managed separately.
func (s *Service) ListCompaniesWithoutActivePlan(ctx context.Context, limit, offset int) ([]CompanyWithoutActivePlan, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
rows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name, c.language, COALESCE(c.legacy_company_id, ''), c.created_at
FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($3)
ORDER BY c.created_at DESC
LIMIT $1 OFFSET $2`, limit, offset, A1LegacyCompanyID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]CompanyWithoutActivePlan, 0)
for rows.Next() {
var c CompanyWithoutActivePlan
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.LegacyCompanyID, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CountCompaniesWithoutActivePlan returns how many companies lack an active plan.
// Excludes the A1 cohort (same filter as ListCompaniesWithoutActivePlan).
func (s *Service) CountCompaniesWithoutActivePlan(ctx context.Context) (int64, error) {
var n int64
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($1)`, A1LegacyCompanyID).Scan(&n)
return n, err
}
// AssignPlanIfMissing assigns planID only when the company has no active plan.
// Does not deactivate or replace an existing active plan (safe cutover repair).
// Returns assigned=false when the company already has an active plan.
func (s *Service) AssignPlanIfMissing(ctx context.Context, companyID uuid.UUID, planID int64) (assigned bool, err error) {
has, err := s.HasActivePlan(ctx, companyID)
if err != nil {
return false, err
}
if has {
return false, nil
}
if err := s.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
return false, err
}
return true, nil
}
// AssignPlanByNameIfMissing resolves planName then AssignPlanIfMissing.
func (s *Service) AssignPlanByNameIfMissing(ctx context.Context, companyID uuid.UUID, planName string) (assigned bool, err error) {
planID, err := s.PlanIDByName(ctx, planName)
if err != nil {
return false, err
}
return s.AssignPlanIfMissing(ctx, companyID, planID)
}
@@ -0,0 +1,142 @@
package billing
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestPlanIDByNameRequiresName(t *testing.T) {
t.Parallel()
svc := &Service{}
_, err := svc.PlanIDByName(context.Background(), " ")
if !errors.Is(err, ErrPlanNameRequired) {
t.Fatalf("got %v, want ErrPlanNameRequired", err)
}
}
func TestAssignPlanIfMissingSkipsExisting(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
svc := &Service{Pool: pg}
if err := svc.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
freeID, err := svc.PlanIDByName(ctx, "Free")
if err != nil {
t.Fatal(err)
}
starterID, err := svc.PlanIDByName(ctx, "Starter")
if err != nil {
t.Fatal(err)
}
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "missing-plans-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
assigned, err := svc.AssignPlanIfMissing(ctx, companyID, freeID)
if err != nil {
t.Fatal(err)
}
if !assigned {
t.Fatal("expected first assign to succeed")
}
assigned, err = svc.AssignPlanIfMissing(ctx, companyID, starterID)
if err != nil {
t.Fatal(err)
}
if assigned {
t.Fatal("must not overwrite an existing active plan")
}
has, err := svc.HasActivePlan(ctx, companyID)
if err != nil || !has {
t.Fatalf("has active plan: has=%v err=%v", has, err)
}
var planID int64
err = pg.QueryRow(ctx, `SELECT plan_id FROM company_plans WHERE company_id = $1 AND is_active = true`, companyID).Scan(&planID)
if err != nil {
t.Fatal(err)
}
if planID != freeID {
t.Fatalf("active plan_id=%d, want Free id=%d", planID, freeID)
}
missing, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
if err != nil {
t.Fatal(err)
}
for _, c := range missing {
if c.ID == companyID {
t.Fatal("company with active plan must not appear in without-plan list")
}
}
}
func TestListCompaniesWithoutActivePlanIncludesBareCompany(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
svc := &Service{Pool: pg}
companyID := uuid.New()
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "no-plan-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
found := false
rows, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
if err != nil {
t.Fatal(err)
}
for _, c := range rows {
if c.ID == companyID {
found = true
break
}
}
if !found {
t.Fatal("bare company must appear in without-plan list")
}
}
@@ -0,0 +1,71 @@
package billing
import (
"context"
"strings"
)
// IsEphemeralTestPlanName reports integration-test plan rows that should stay
// out of the admin "catalog" filter (consume-contention-*, claim-test-plan-*, multi-plan-*).
func IsEphemeralTestPlanName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return false
}
return strings.HasPrefix(n, "consume-contention-") ||
strings.HasPrefix(n, "claim-test-plan-") ||
strings.HasPrefix(n, "multi-plan-")
}
// IsObsoleteLadderPlanName reports pre-v2 public ladder leftovers that must never
// appear on Choose your plan (Basic / Professional / Merkur / Mini).
func IsObsoleteLadderPlanName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "basic", "professional", "mini", "merkur", "meur", "merkur trial":
return true
default:
return false
}
}
// EnsurePlanCatalogHygiene soft-hides obsolete ladder leftovers and forces EPREL
// on for every plan (public EU data — never credit-gated).
//
// Soft-hide: mark Basic/Professional/… as is_custom with an archived description
// so they never look like self-serve product rows. Rows are not deleted (may be
// referenced by history). Ephemeral test plans are left in DB but filtered in admin UI.
func (s *Service) EnsurePlanCatalogHygiene(ctx context.Context) error {
if s == nil || s.Pool == nil {
return nil
}
_, err := s.Pool.Exec(ctx, `
UPDATE plans SET
is_custom = true,
description = CASE
WHEN description IS NULL OR btrim(description) = '' THEN
'Archived pre-v2 plan (hidden from Choose your plan)'
WHEN description LIKE 'Archived pre-v2%' THEN description
ELSE 'Archived pre-v2 plan (hidden from Choose your plan). ' || description
END,
updated_at = now()
WHERE lower(name) IN ('basic', 'professional', 'mini', 'merkur', 'meur', 'merkur trial')
AND is_custom = false`)
if err != nil {
return err
}
// Never leave an explicit capability.eprel=false override — EPREL is free on all plans.
_, err = s.Pool.Exec(ctx, `
UPDATE plans
SET features = features || '{"capability.eprel": true}'::jsonb,
updated_at = now()
WHERE features ? 'capability.eprel'
AND (features->>'capability.eprel') = 'false'`)
if err != nil {
if isUndefinedColumn(err) {
return nil
}
return err
}
return nil
}
@@ -0,0 +1,52 @@
package billing
import "testing"
func TestIsEphemeralTestPlanName(t *testing.T) {
t.Parallel()
for _, name := range []string{
"consume-contention-abc",
"claim-test-plan-14c023e2",
"multi-plan-a6b5d552",
} {
if !IsEphemeralTestPlanName(name) {
t.Fatalf("expected ephemeral: %q", name)
}
}
for _, name := range []string{"Free", "A1", "Platform Demo", "Legacy", ""} {
if IsEphemeralTestPlanName(name) {
t.Fatalf("expected non-ephemeral: %q", name)
}
}
}
func TestIsObsoleteLadderPlanName(t *testing.T) {
t.Parallel()
for _, name := range []string{"Basic", "Professional", "Merkur trial", "Mini", "Meur"} {
if !IsObsoleteLadderPlanName(name) {
t.Fatalf("expected obsolete: %q", name)
}
if IsPublicProductPlan(name) {
t.Fatalf("obsolete must not be public: %q", name)
}
}
for _, name := range []string{"Free", "Starter", "A1", "Platform Demo"} {
if IsObsoleteLadderPlanName(name) {
t.Fatalf("expected retained: %q", name)
}
}
}
func TestPlanAllowsEPRELOnFree(t *testing.T) {
t.Parallel()
if !PlanAllowsFeature("Free", false, nil, "capability.eprel") {
t.Fatal("Free must include capability.eprel")
}
if !PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": true}, "capability.eprel") {
t.Fatal("explicit true override must allow EPREL")
}
// Explicit false is still honored at plan_allows level; hygiene clears it in DB.
if PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": false}, "capability.eprel") {
t.Fatal("explicit false override still wins until hygiene clears it")
}
}
+650
View File
@@ -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")
}
@@ -0,0 +1,52 @@
package billing
import (
"strings"
"testing"
)
func TestIsPublicProductPlan(t *testing.T) {
public := []string{"Free", "Starter", "Growth", "Business", "Enterprise", " free ", "GROWTH"}
for _, name := range public {
if !IsPublicProductPlan(name) {
t.Fatalf("expected public: %q", name)
}
}
hidden := []string{"A1", "Merkur trial", "Merkur", "Meur", "Basic", "Professional", "Mini", ""}
for _, name := range hidden {
if IsPublicProductPlan(name) {
t.Fatalf("expected hidden from public pricing: %q", name)
}
}
}
func TestFilterPublicPlans(t *testing.T) {
all := []Plan{
{Name: "Basic"},
{Name: "A1", IsCustom: true},
{Name: "Merkur trial", IsCustom: true},
{Name: "Free"},
{Name: "Starter"},
{Name: "Growth"},
{Name: "Business"},
{Name: "Enterprise", IsCustom: true},
{Name: "Professional"},
}
out := make([]Plan, 0, 5)
for _, p := range all {
if IsPublicProductPlan(p.Name) {
out = append(out, p)
}
}
if len(out) != 5 {
t.Fatalf("got %d public plans, want 5: %+v", len(out), out)
}
for _, p := range out {
key := strings.ToLower(p.Name)
switch key {
case "free", "starter", "growth", "business", "enterprise":
default:
t.Fatalf("unexpected public plan %q", p.Name)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) {
t.Helper()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() { pg.Close() })
return pg, ctx, cancel
}
func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID {
t.Helper()
companyID := uuid.New()
_, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8])
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanupCancel()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
return companyID
}
func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int {
t.Helper()
var total int
err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total)
if err != nil {
return 0
}
return total
}
func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string {
t.Helper()
var name string
err := pg.QueryRow(ctx, `
SELECT lower(p.name) 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(&name)
if err != nil {
return ""
}
return name
}
func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
if err := billing.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
}
res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"})
if err != nil {
t.Fatal(err)
}
if !res.Mock || !res.Applied {
t.Fatalf("expected mock applied checkout, got %#v", res)
}
if activePlanName(t, pg, ctx, companyID) != "starter" {
t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID))
}
want := MonthlyCreditsForPlan("Starter", 0)
if got := creditTotal(t, pg, ctx, companyID); got != want {
t.Fatalf("credits=%d want %d", got, want)
}
var subID *string
_ = pg.QueryRow(ctx, `
SELECT stripe_subscription_id FROM company_plans
WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID)
if subID == nil || *subID == "" {
t.Fatal("mock checkout must set stripe_subscription_id")
}
}
func TestMockCreditPackCheckoutGrants(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
}
before := creditTotal(t, pg, ctx, companyID)
res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small")
if err != nil {
t.Fatal(err)
}
if !res.Mock || !res.Applied {
t.Fatalf("expected mock applied pack, got %#v", res)
}
pack, _ := CreditPackByID("small")
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
t.Fatalf("credits=%d want %d", got, before+pack.Credits)
}
}
func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret
}
eventID := "evt_mock_credit_" + companyID.String()[:8]
payload, err := json.Marshal(map[string]any{
"id": eventID,
"type": "checkout.session.completed",
"data": map[string]any{
"object": map[string]any{
"id": "cs_mock_1",
"client_reference_id": companyID.String(),
"metadata": map[string]string{
"kind": "credit_pack",
"pack": "tiny",
"credits": "9999", // must be ignored for catalog pack
},
},
},
})
if err != nil {
t.Fatal(err)
}
before := creditTotal(t, pg, ctx, companyID)
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
pack, _ := CreditPackByID("tiny")
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits)
}
mid := creditTotal(t, pg, ctx, companyID)
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
if got := creditTotal(t, pg, ctx, companyID); got != mid {
t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid)
}
}
func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
billing := &Service{Pool: pg}
if err := billing.EnsureDefaultPlans(ctx); err != nil {
t.Fatal(err)
}
s := &StripeService{
Pool: pg,
Billing: billing,
Cfg: StripeConfig{ForceMock: true},
}
if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil {
t.Fatal(err)
}
if activePlanName(t, pg, ctx, companyID) != "plus" {
t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID))
}
eventID := "evt_mock_del_" + companyID.String()[:8]
payload, err := json.Marshal(map[string]any{
"id": eventID,
"type": "customer.subscription.deleted",
"data": map[string]any{
"object": map[string]any{
"id": "sub_mock_del",
"customer": "cus_mock",
"status": "canceled",
"metadata": map[string]string{"company_id": companyID.String()},
},
},
})
if err != nil {
t.Fatal(err)
}
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
t.Fatal(err)
}
if got := activePlanName(t, pg, ctx, companyID); got != "free" {
t.Fatalf("after delete plan=%q want free", got)
}
}
func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) {
pg, ctx, cancel := openStripeMockPool(t)
defer cancel()
companyID := seedStripeMockCompany(t, pg, ctx)
secret := "whsec_mock_local"
s := &StripeService{
Pool: pg,
Billing: &Service{Pool: pg},
Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret},
}
payload, err := json.Marshal(map[string]any{
"id": "evt_signed_" + companyID.String()[:8],
"type": "ping",
"data": map[string]any{"object": map[string]any{}},
})
if err != nil {
t.Fatal(err)
}
if err := s.HandleWebhook(ctx, payload, ""); err == nil {
t.Fatal("unsigned must fail when webhook secret set")
}
sig := signStripePayload(t, secret, payload)
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
t.Fatalf("valid signature under ForceMock: %v", err)
}
// Second delivery is an idempotent no-op.
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
t.Fatal(err)
}
var n int
if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`,
fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("claim rows=%d want 1", n)
}
}
@@ -0,0 +1,304 @@
package billing
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// SalesQuoteCheckoutInput drives Checkout for an admin-prepared custom deal.
type SalesQuoteCheckoutInput struct {
QuoteID uuid.UUID
CompanyID uuid.UUID
PlanID int64
PlanName string
Email string
CompanyName string
Currency string
TotalAmountCents int
InstallmentCount int
InstallmentInterval string // month | quarter | year
InstallmentAmountCents int
}
// SalesQuoteCheckoutResult is returned to admin after preparing Checkout for a quote.
type SalesQuoteCheckoutResult struct {
URL string `json:"url"`
Mock bool `json:"mock"`
Applied bool `json:"applied,omitempty"`
Message string `json:"message,omitempty"`
ProductID string `json:"product_id,omitempty"`
PriceID string `json:"price_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// CreateSalesQuoteCheckout creates a Stripe Price + Checkout Session for a sales quote.
//
// ASSUMPTION (installments):
// - installment_count == 1 → Checkout mode=payment (one-time Price).
// - installment_count > 1 → Checkout mode=subscription with a recurring Price equal to
// installment_amount_cents; subscription_data.cancel_at ends billing after N intervals
// (month / quarter=3 months / year). Stripe collects the first installment at Checkout;
// later invoices are charged automatically on the subscription.
func (s *StripeService) CreateSalesQuoteCheckout(ctx context.Context, in SalesQuoteCheckoutInput) (SalesQuoteCheckoutResult, error) {
if in.QuoteID == uuid.Nil || in.CompanyID == uuid.Nil || in.PlanID <= 0 {
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: quote identifiers", ErrStripePlanUnsupported)
}
if in.InstallmentAmountCents <= 0 || in.TotalAmountCents <= 0 {
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: amounts", ErrStripePlanUnsupported)
}
count := in.InstallmentCount
if count <= 0 {
count = 1
}
interval := strings.ToLower(strings.TrimSpace(in.InstallmentInterval))
if interval == "" {
interval = "month"
}
currency := strings.ToLower(strings.TrimSpace(in.Currency))
if currency == "" {
currency = "usd"
}
ctx, cfg, err := s.bindCfg(ctx)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
web := strings.TrimRight(cfg.WebOrigin, "/")
if web == "" {
web = "http://localhost:5174"
}
if cfg.AllowMockPurchase() {
if err := s.applySalesQuotePurchase(ctx, in.CompanyID, in.PlanID, in.QuoteID, "cus_mock_"+in.CompanyID.String()[:8], "sub_mock_"+uuid.NewString()[:8], "price_mock_quote"); err != nil {
return SalesQuoteCheckoutResult{}, err
}
return SalesQuoteCheckoutResult{
URL: web + "/billing?checkout=success&mock=1&sales_quote=" + url.QueryEscape(in.QuoteID.String()),
Mock: true,
Applied: true,
Message: "Mock mode: custom sales quote plan assigned without Stripe.",
}, nil
}
if cfg.MockMode() {
return SalesQuoteCheckoutResult{}, ErrStripeNotConfigured
}
productID, err := s.ensureSalesQuoteProduct(ctx, in)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
priceID, err := s.createSalesQuotePrice(ctx, productID, in, count == 1)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
customerID, err := s.ensureCustomer(ctx, in.CompanyID, in.Email, in.CompanyName)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
form := url.Values{}
form.Set("success_url", web+"/billing?checkout=success&sales_quote="+url.QueryEscape(in.QuoteID.String()))
form.Set("cancel_url", web+"/billing?checkout=cancel&sales_quote="+url.QueryEscape(in.QuoteID.String()))
form.Set("client_reference_id", in.CompanyID.String())
form.Set("metadata[company_id]", in.CompanyID.String())
form.Set("metadata[kind]", "sales_quote")
form.Set("metadata[quote_id]", in.QuoteID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
form.Set("metadata[plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
form.Set("metadata[installment_count]", strconv.Itoa(count))
form.Set("metadata[installment_interval]", interval)
form.Set("line_items[0][price]", priceID)
form.Set("line_items[0][quantity]", "1")
form.Set("allow_promotion_codes", "true")
if customerID != "" {
form.Set("customer", customerID)
} else if in.Email != "" {
form.Set("customer_email", in.Email)
}
if count == 1 {
form.Set("mode", "payment")
form.Set("payment_intent_data[metadata][company_id]", in.CompanyID.String())
form.Set("payment_intent_data[metadata][kind]", "sales_quote")
form.Set("payment_intent_data[metadata][quote_id]", in.QuoteID.String())
form.Set("payment_intent_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
} else {
form.Set("mode", "subscription")
form.Set("subscription_data[metadata][company_id]", in.CompanyID.String())
form.Set("subscription_data[metadata][kind]", "sales_quote")
form.Set("subscription_data[metadata][quote_id]", in.QuoteID.String())
form.Set("subscription_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
form.Set("subscription_data[metadata][plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
cancelAt, err := salesQuoteCancelAt(time.Now().UTC(), count, interval)
if err != nil {
return SalesQuoteCheckoutResult{}, err
}
form.Set("subscription_data[cancel_at]", strconv.FormatInt(cancelAt.Unix(), 10))
}
var sess struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil {
return SalesQuoteCheckoutResult{}, err
}
if sess.URL == "" {
return SalesQuoteCheckoutResult{}, errors.New("stripe checkout session missing url")
}
return SalesQuoteCheckoutResult{
URL: sess.URL,
Mock: false,
ProductID: productID,
PriceID: priceID,
SessionID: sess.ID,
}, nil
}
func (s *StripeService) ensureSalesQuoteProduct(ctx context.Context, in SalesQuoteCheckoutInput) (string, error) {
metaKey := in.QuoteID.String()
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_sales_quote']:'%s'", metaKey))
var search struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
return "", err
}
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
return search.Data[0].ID, nil
}
form := url.Values{}
name := strings.TrimSpace(in.PlanName)
if name == "" {
name = "Custom Descrybe plan"
}
form.Set("name", "Descrybe — "+name)
form.Set("description", fmt.Sprintf("Sales quote %s (%d installments)", in.QuoteID.String(), in.InstallmentCount))
form.Set("metadata[descrybe_sales_quote]", metaKey)
form.Set("metadata[company_id]", in.CompanyID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
var product struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
return "", err
}
if strings.TrimSpace(product.ID) == "" {
return "", fmt.Errorf("stripe product missing id")
}
return product.ID, nil
}
func (s *StripeService) createSalesQuotePrice(ctx context.Context, productID string, in SalesQuoteCheckoutInput, oneTime bool) (string, error) {
form := url.Values{}
form.Set("product", productID)
form.Set("currency", strings.ToLower(strings.TrimSpace(in.Currency)))
if form.Get("currency") == "" {
form.Set("currency", "usd")
}
form.Set("unit_amount", strconv.Itoa(in.InstallmentAmountCents))
form.Set("metadata[descrybe_sales_quote]", in.QuoteID.String())
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
if oneTime {
// default type one_time
} else {
stripeInterval, intervalCount, err := stripeRecurringFromInstallment(in.InstallmentInterval)
if err != nil {
return "", err
}
form.Set("recurring[interval]", stripeInterval)
if intervalCount > 1 {
form.Set("recurring[interval_count]", strconv.Itoa(intervalCount))
}
}
var price struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
return "", err
}
if strings.TrimSpace(price.ID) == "" {
return "", fmt.Errorf("stripe price missing id")
}
return price.ID, nil
}
func stripeRecurringFromInstallment(interval string) (stripeInterval string, intervalCount int, err error) {
switch strings.ToLower(strings.TrimSpace(interval)) {
case "month", "":
return "month", 1, nil
case "quarter":
return "month", 3, nil
case "year":
return "year", 1, nil
default:
return "", 0, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
}
}
func salesQuoteCancelAt(now time.Time, count int, interval string) (time.Time, error) {
if count < 1 {
return time.Time{}, fmt.Errorf("%w: installment count", ErrStripePlanUnsupported)
}
switch strings.ToLower(strings.TrimSpace(interval)) {
case "month", "":
return now.AddDate(0, count, 0), nil
case "quarter":
return now.AddDate(0, count*3, 0), nil
case "year":
return now.AddDate(count, 0, 0), nil
default:
return time.Time{}, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
}
}
func (s *StripeService) applySalesQuotePurchase(ctx context.Context, companyID uuid.UUID, planID int64, quoteID uuid.UUID, customerID, subscriptionID, priceID string) error {
if s.Billing == nil {
return errors.New("billing service not configured")
}
if planID <= 0 {
return fmt.Errorf("%w: plan_id", ErrStripePlanUnsupported)
}
if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
return err
}
if customerID != "" {
_, _ = s.Pool.Exec(ctx, `
UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`,
companyID, customerID)
}
_, _ = s.Pool.Exec(ctx, `
UPDATE company_plans
SET stripe_subscription_id = NULLIF($2, ''), stripe_price_id = NULLIF($3, ''), updated_at = now()
WHERE company_id = $1 AND is_active = true`,
companyID, subscriptionID, priceID)
_ = s.setSubscriptionStatusNote(ctx, companyID, "active")
if quoteID != uuid.Nil {
_, err := s.Pool.Exec(ctx, `
UPDATE sales_quotes
SET status = 'paid', paid_at = COALESCE(paid_at, now()), updated_at = now()
WHERE id = $1 AND status <> 'canceled'`, quoteID)
if err != nil {
return err
}
_, _ = s.Pool.Exec(ctx, `
UPDATE sales_leads
SET status = 'won', updated_at = now()
WHERE id = (SELECT lead_id FROM sales_quotes WHERE id = $1)
AND status <> 'closed'`, quoteID)
}
return nil
}
@@ -0,0 +1,46 @@
package billing
import (
"testing"
"time"
)
func TestSalesQuoteCancelAt(t *testing.T) {
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
got, err := salesQuoteCancelAt(now, 4, "month")
if err != nil {
t.Fatal(err)
}
want := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("month cancel_at = %v, want %v", got, want)
}
got, err = salesQuoteCancelAt(now, 2, "quarter")
if err != nil {
t.Fatal(err)
}
want = time.Date(2027, 2, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("quarter cancel_at = %v, want %v", got, want)
}
got, err = salesQuoteCancelAt(now, 1, "year")
if err != nil {
t.Fatal(err)
}
want = time.Date(2027, 8, 8, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("year cancel_at = %v, want %v", got, want)
}
}
func TestStripeRecurringFromInstallment(t *testing.T) {
iv, n, err := stripeRecurringFromInstallment("quarter")
if err != nil || iv != "month" || n != 3 {
t.Fatalf("quarter => %s/%d err=%v", iv, n, err)
}
iv, n, err = stripeRecurringFromInstallment("month")
if err != nil || iv != "month" || n != 1 {
t.Fatalf("month => %s/%d err=%v", iv, n, err)
}
}
@@ -0,0 +1,127 @@
package billing
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
// SyncCreditPackResult is one pack after Stripe Product/Price ensure.
type SyncCreditPackResult struct {
PackID string `json:"pack_id"`
ProductID string `json:"product_id"`
PriceID string `json:"price_id"`
Created bool `json:"created"`
Credits int `json:"credits"`
PriceUSD int `json:"price_usd"`
}
// SyncCreditPackProducts creates/updates Stripe Products + one-time Prices for
// DefaultCreditPacks. Packs are additional one-time products (Checkout mode=payment),
// not subscription add-ons. Metadata descrybe_pack=<id> identifies each product.
func (s *StripeService) SyncCreditPackProducts(ctx context.Context) ([]SyncCreditPackResult, error) {
ctx, cfg, err := s.bindCfg(ctx)
if err != nil {
return nil, err
}
if cfg.MockMode() || strings.TrimSpace(cfg.SecretKey) == "" {
return nil, ErrStripeNotConfigured
}
out := make([]SyncCreditPackResult, 0, len(DefaultCreditPacks()))
for _, pack := range DefaultCreditPacks() {
productID, createdProduct, err := s.ensureCreditPackProduct(ctx, pack)
if err != nil {
return out, fmt.Errorf("pack %s product: %w", pack.ID, err)
}
priceID, createdPrice, err := s.ensureCreditPackPrice(ctx, productID, pack)
if err != nil {
return out, fmt.Errorf("pack %s price: %w", pack.ID, err)
}
out = append(out, SyncCreditPackResult{
PackID: pack.ID,
ProductID: productID,
PriceID: priceID,
Created: createdProduct || createdPrice,
Credits: pack.Credits,
PriceUSD: pack.PriceUSD,
})
}
return out, nil
}
func (s *StripeService) ensureCreditPackProduct(ctx context.Context, pack CreditPack) (productID string, created bool, err error) {
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_pack']:'%s'", pack.ID))
var search struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
return "", false, err
}
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
return search.Data[0].ID, false, nil
}
form := url.Values{}
form.Set("name", "Descrybe AI credits — "+pack.Name)
form.Set("description", pack.Description)
form.Set("metadata[descrybe_pack]", pack.ID)
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
form.Set("metadata[ai_products]", strconv.Itoa(pack.AIProducts))
form.Set("metadata[price_usd]", strconv.Itoa(pack.PriceUSD))
var product struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
return "", false, err
}
if strings.TrimSpace(product.ID) == "" {
return "", false, fmt.Errorf("stripe product missing id")
}
return product.ID, true, nil
}
func (s *StripeService) ensureCreditPackPrice(ctx context.Context, productID string, pack CreditPack) (priceID string, created bool, err error) {
// Reuse an active one-time price on this product that matches unit amount.
wantCents := pack.PriceUSD * 100
var list struct {
Data []struct {
ID string `json:"id"`
UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"`
Type string `json:"type"`
Active bool `json:"active"`
} `json:"data"`
}
endpoint := "https://api.stripe.com/v1/prices?product=" + url.QueryEscape(productID) + "&active=true&limit=20"
if err := s.stripeGET(ctx, endpoint, &list); err != nil {
return "", false, err
}
for _, p := range list.Data {
if p.Active && p.Type == "one_time" && strings.EqualFold(p.Currency, "usd") && int(p.UnitAmount) == wantCents {
return p.ID, false, nil
}
}
form := url.Values{}
form.Set("product", productID)
form.Set("currency", "usd")
form.Set("unit_amount", strconv.Itoa(wantCents))
form.Set("metadata[descrybe_pack]", pack.ID)
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
var price struct {
ID string `json:"id"`
}
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
return "", false, err
}
if strings.TrimSpace(price.ID) == "" {
return "", false, fmt.Errorf("stripe price missing id")
}
return price.ID, true, nil
}
+350
View File
@@ -0,0 +1,350 @@
package billing
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"testing"
"time"
"github.com/google/uuid"
)
func TestNormalizePlanTerm(t *testing.T) {
plan, term, err := normalizePlanTerm("Starter", "YEARLY")
if err != nil || plan != "starter" || term != "yearly" {
t.Fatalf("got %s %s err=%v", plan, term, err)
}
plan, term, err = normalizePlanTerm("Plus", "monthly")
if err != nil || plan != "plus" || term != "monthly" {
t.Fatalf("plus: got %s %s err=%v", plan, term, err)
}
plan, term, err = normalizePlanTerm("scale", "yearly")
if err != nil || plan != "scale" || term != "yearly" {
t.Fatalf("scale: got %s %s err=%v", plan, term, err)
}
_, _, err = normalizePlanTerm("enterprise", "monthly")
if err == nil {
t.Fatal("enterprise must be rejected")
}
_, _, err = normalizePlanTerm("free", "monthly")
if err == nil {
t.Fatal("free must be rejected")
}
}
func TestCheckoutReturnURL(t *testing.T) {
success := checkoutReturnURL("https://app.example", "success", "starter", "monthly", "", true)
if success != "https://app.example/billing?checkout=success&plan=starter&term=monthly&session_id={CHECKOUT_SESSION_ID}" {
t.Fatalf("success url: %s", success)
}
cancel := checkoutReturnURL("https://app.example/", "cancel", "starter", "yearly", "", false)
if cancel != "https://app.example/billing?checkout=cancel&plan=starter&term=yearly" {
t.Fatalf("cancel url: %s", cancel)
}
pack := checkoutReturnURL("https://app.example", "success", "", "", "tiny", true)
if pack != "https://app.example/billing?checkout=success&pack=tiny&session_id={CHECKOUT_SESSION_ID}" {
t.Fatalf("pack url: %s", pack)
}
packCancel := checkoutReturnURL("https://app.example", "cancel", "", "", "tiny", false)
if packCancel != "https://app.example/billing?checkout=cancel&pack=tiny" {
t.Fatalf("pack cancel url: %s", packCancel)
}
}
func TestVerifyStripeSignature(t *testing.T) {
secret := "whsec_test_secret"
payload := []byte(`{"id":"evt_1","type":"checkout.session.completed"}`)
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "%d.", ts)
_, _ = mac.Write(payload)
sig := hex.EncodeToString(mac.Sum(nil))
header := fmt.Sprintf("t=%d,v1=%s", ts, sig)
if err := verifyStripeSignature(payload, header, secret, 5*time.Minute); err != nil {
t.Fatal(err)
}
if err := verifyStripeSignature(payload, "t="+fmt.Sprint(ts)+",v1=deadbeef", secret, 5*time.Minute); err == nil {
t.Fatal("expected bad signature")
}
}
func TestStripeConfigMockMode(t *testing.T) {
if !(StripeConfig{}).MockMode() {
t.Fatal("empty secret should be mock")
}
if (StripeConfig{SecretKey: "sk_test_x"}).MockMode() {
t.Fatal("secret set should not mock")
}
if !(StripeConfig{SecretKey: "sk_test_x", ForceMock: true}).MockMode() {
t.Fatal("ForceMock should override")
}
if (StripeConfig{}).AllowMockPurchase() {
t.Fatal("empty secret alone must not allow mock purchase")
}
if (StripeConfig{SecretKey: "sk_test_x"}).AllowMockPurchase() {
t.Fatal("live secret must not allow mock purchase")
}
if !(StripeConfig{ForceMock: true}).AllowMockPurchase() {
t.Fatal("ForceMock should allow mock purchase")
}
}
func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
if !errors.Is(err, ErrStripeNotConfigured) {
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
}
}
func TestHandleWebhookRejectsUnsignedWithoutForceMock(t *testing.T) {
// Empty secret (MockMode) without ForceMock must still reject unsigned webhooks.
s := &StripeService{Cfg: StripeConfig{}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_x","type":"ping"}`), "")
if err != ErrStripeNotConfigured {
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
}
s2 := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}}
err = s2.HandleWebhook(context.TODO(), []byte(`{"id":"evt_y","type":"ping"}`), "")
if err != ErrStripeNotConfigured {
t.Fatalf("live without webhook secret: want ErrStripeNotConfigured, got %v", err)
}
}
func TestHandleWebhookVerifiesEvenWhenForceMock(t *testing.T) {
secret := "whsec_test_secret"
payload := []byte(`{"id":"evt_1","type":"ping"}`)
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret}}
err := s.HandleWebhook(context.TODO(), payload, "t=1,v1=deadbeef")
if !errors.Is(err, ErrStripeBadSignature) {
t.Fatalf("ForceMock must still verify when WebhookSecret set, got %v", err)
}
}
func TestHandleWebhookRejectsUnsignedInProductionEvenWithForceMock(t *testing.T) {
t.Setenv("APP_ENV", "production")
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_prod","type":"ping"}`), "")
if !errors.Is(err, ErrStripeNotConfigured) {
t.Fatalf("production must reject unsigned ForceMock webhooks, got %v", err)
}
}
func TestLoadStripePriceIDs(t *testing.T) {
m := LoadStripePriceIDs(func(k string) string {
switch k {
case "STRIPE_PRICE_STARTER_MONTHLY":
return "price_starter_m"
case "STRIPE_PRICE_PACK_SMALL":
return "price_pack_s"
default:
return ""
}
})
if m["starter:monthly"] != "price_starter_m" {
t.Fatalf("got %#v", m)
}
if m["pack:small"] != "price_pack_s" {
t.Fatalf("pack missing: %#v", m)
}
}
func TestPlanFromPriceIDIgnoresPacks(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
"growth:monthly": "price_g_m",
"pack:small": "price_pack_s",
}}}
if got := s.planFromPriceID("price_g_m"); got != "growth" {
t.Fatalf("got %q", got)
}
if got := s.planFromPriceID("price_pack_s"); got != "" {
t.Fatalf("pack price must not map to a plan, got %q", got)
}
}
func TestCreditPackCatalog(t *testing.T) {
if got := MonthlyCreditsForPlan("Starter", 0); got != 100 {
t.Fatalf("starter cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Plus", 0); got != 400 {
t.Fatalf("plus cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Growth", 0); got != 1200 {
t.Fatalf("growth cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Business", 0); got != 4000 {
t.Fatalf("business cover: got %d", got)
}
if got := MonthlyCreditsForPlan("Scale", 0); got != 12000 {
t.Fatalf("scale cover: got %d", got)
}
packs := DefaultCreditPacks()
if len(packs) < 7 {
t.Fatalf("want at least 7 packs, got %d", len(packs))
}
tiny, ok := CreditPackByID("tiny")
if !ok || tiny.Credits != 25 || tiny.PriceUSD != 29 {
t.Fatalf("tiny pack: %#v ok=%v", tiny, ok)
}
small, ok := CreditPackByID("small")
if !ok || small.Credits != 65 || small.PriceUSD != 59 {
t.Fatalf("small pack: %#v ok=%v", small, ok)
}
med, ok := CreditPackByID("medium")
if !ok || med.Credits != 200 || med.PriceUSD != 149 {
t.Fatalf("medium pack: %#v ok=%v", med, ok)
}
mega, ok := CreditPackByID("mega")
if !ok || mega.Credits != 8000 || mega.PriceUSD != 2999 {
t.Fatalf("mega pack: %#v ok=%v", mega, ok)
}
if CreditPackSettingsKey("small") != "stripe.price.pack.small" {
t.Fatalf("settings key")
}
if CreditPackEnvVar("xxl") != "STRIPE_PRICE_PACK_XXL" {
t.Fatalf("env var")
}
if _, ok := CreditPackByID("nope"); ok {
t.Fatal("unknown pack must miss")
}
}
func TestPlanFromPriceID(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
"growth:monthly": "price_g_m",
}}}
if got := s.planFromPriceID("price_g_m"); got != "growth" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeSubscriptionStatus(t *testing.T) {
if got := NormalizeSubscriptionStatus(" Past_Due "); got != "past_due" {
t.Fatalf("got %q", got)
}
}
func TestIsPastDueSubscriptionStatus(t *testing.T) {
if !IsPastDueSubscriptionStatus("past_due") {
t.Fatal("expected past_due")
}
if !IsPastDueSubscriptionStatus(" Past_Due ") {
t.Fatal("expected normalized past_due")
}
if IsPastDueSubscriptionStatus("active") {
t.Fatal("active must not be past_due")
}
if IsPastDueSubscriptionStatus("") {
t.Fatal("empty must not be past_due")
}
}
func TestParseStripeStatusNote(t *testing.T) {
note := FormatStripeStatusNote("Past_Due")
if note != "stripe_status:past_due" {
t.Fatalf("format got %q", note)
}
if got := ParseStripeStatusNote(&note); got != "past_due" {
t.Fatalf("parse got %q", got)
}
ops := "ops: keep forever"
if got := ParseStripeStatusNote(&ops); got != "" {
t.Fatalf("ops notes must be ignored, got %q", got)
}
if got := ParseStripeStatusNote(nil); got != "" {
t.Fatalf("nil got %q", got)
}
}
func TestCreatePortalSessionMock(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
res, err := s.CreatePortalSession(context.TODO(), uuid.New())
if err != nil {
t.Fatal(err)
}
if !res.Mock || res.URL != "http://localhost:5174/billing?portal=mock" {
t.Fatalf("got %#v", res)
}
// Empty secret alone (MockMode without ForceMock) also returns mock portal deep-link.
s2 := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
res, err = s2.CreatePortalSession(context.TODO(), uuid.New())
if err != nil {
t.Fatal(err)
}
if !res.Mock {
t.Fatalf("mock mode portal expected, got %#v", res)
}
}
func TestCreateCheckoutSessionMockRequiresBilling(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCheckoutSession(context.TODO(), uuid.New(), "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
if err == nil || err.Error() != "billing service not configured" {
t.Fatalf("want billing not configured, got %v", err)
}
}
func TestCreateCreditPackCheckoutMockRequiresBilling(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "small")
if err == nil || err.Error() != "billing service not configured" {
t.Fatalf("want billing not configured, got %v", err)
}
_, err = s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "nope")
if !errors.Is(err, ErrStripePlanUnsupported) {
t.Fatalf("unknown pack: %v", err)
}
}
func TestCreditsFromPackMetadata(t *testing.T) {
got, err := creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "999999"})
if err != nil {
t.Fatal(err)
}
if got != 65 {
t.Fatalf("catalog must win over inflated credits, got %d", got)
}
got, err = creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "not-a-number"})
if err != nil {
t.Fatal(err)
}
if got != 65 {
t.Fatalf("catalog must win over garbage credits, got %d", got)
}
_, err = creditsFromPackMetadata(map[string]string{"pack": "unknown", "credits": "abc"})
if err == nil {
t.Fatal("unknown pack with garbage credits must fail")
}
got, err = creditsFromPackMetadata(map[string]string{"pack": "custom", "credits": "42"})
if err != nil {
t.Fatal(err)
}
if got != 42 {
t.Fatalf("unknown pack may use positive credits metadata, got %d", got)
}
_, err = creditsFromPackMetadata(map[string]string{"pack": "nope"})
if !errors.Is(err, ErrStripePlanUnsupported) {
t.Fatalf("empty credits unknown pack: %v", err)
}
}
func TestHandleWebhookForceMockUnsignedNeedsStore(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_local","type":"ping"}`), "")
if err == nil || err.Error() != "stripe store not configured" {
t.Fatalf("want store not configured, got %v", err)
}
}
func signStripePayload(t *testing.T, secret string, payload []byte) string {
t.Helper()
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "%d.", ts)
_, _ = mac.Write(payload)
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
}
+23
View File
@@ -0,0 +1,23 @@
package billing
import "testing"
func TestParseUsageRange(t *testing.T) {
t.Parallel()
cases := []struct {
in, want string
}{
{"", "30d"},
{"7d", "7d"},
{"30D", "30d"},
{" cycle ", "cycle"},
{"all", "all"},
{"week", "30d"},
{"90d", "30d"},
}
for _, c := range cases {
if got := ParseUsageRange(c.in); got != c.want {
t.Fatalf("ParseUsageRange(%q)=%q want %q", c.in, got, c.want)
}
}
}