Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¬e); 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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user