diff --git a/backend/internal/domain/account/account.go b/backend/internal/domain/account/account.go index cfd372291..6fd4aaa7d 100644 --- a/backend/internal/domain/account/account.go +++ b/backend/internal/domain/account/account.go @@ -558,12 +558,15 @@ func normalizeBillingPlan(value string) string { } func isPaidBillingPlan(value string) bool { - switch normalizeBillingPlan(value) { - case "super", "supergrok", "supergrokpro", "supergrokheavy", "supergroklite", + normalized := normalizeBillingPlan(value) + switch normalized { + case "super", "supergrok", "supergrokpro", "supergrokheavy", "supergroklite", "supergrokplus", "grokpro", "xpremium", "xpremiumplus", "apikey": return true default: - return false + // SuperGrok Plus and later SuperGrok* tiers should stay paid even when + // weekly numeric limits are zero and the exact plan name is new. + return strings.HasPrefix(normalized, "supergrok") } } diff --git a/backend/internal/domain/account/account_test.go b/backend/internal/domain/account/account_test.go index 5abad468c..cdf679f06 100644 --- a/backend/internal/domain/account/account_test.go +++ b/backend/internal/domain/account/account_test.go @@ -16,6 +16,8 @@ func TestBillingIsPaidMatchesSQLSignals(t *testing.T) { {PrepaidBalance: 5}, {PlanName: "SuperGrok"}, {PlanName: "SuperGrok Heavy"}, + {PlanName: "SuperGrokPlus"}, + {PlanName: "SuperGrok Plus"}, {PlanCode: "supergrok_lite"}, {PlanName: "X Premium+"}, } { diff --git a/backend/internal/infra/persistence/relational/account_repository.go b/backend/internal/infra/persistence/relational/account_repository.go index faf6104e0..e8d8d0c51 100644 --- a/backend/internal/infra/persistence/relational/account_repository.go +++ b/backend/internal/infra/persistence/relational/account_repository.go @@ -40,7 +40,10 @@ type quotaBreakdownJSON struct { const ( accountUpdateBatchSize = 500 - accountPaidPlanSignal = `(LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_code), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('super', 'supergrok', 'supergrokpro', 'supergrokheavy', 'supergroklite', 'grokpro', 'xpremium', 'xpremiumplus', 'apikey') OR LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_name), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('super', 'supergrok', 'supergrokpro', 'supergrokheavy', 'supergroklite', 'grokpro', 'xpremium', 'xpremiumplus', 'apikey'))` + accountNormalizedPlanCode = `LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_code), ' ', ''), '_', ''), '-', ''), '+', 'plus'))` + accountNormalizedPlanName = `LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_name), ' ', ''), '_', ''), '-', ''), '+', 'plus'))` + accountPaidPlanNames = `'super', 'supergrok', 'supergrokpro', 'supergrokheavy', 'supergroklite', 'supergrokplus', 'grokpro', 'xpremium', 'xpremiumplus', 'apikey'` + accountPaidPlanSignal = `(` + accountNormalizedPlanCode + ` IN (` + accountPaidPlanNames + `) OR ` + accountNormalizedPlanName + ` IN (` + accountPaidPlanNames + `) OR substr(` + accountNormalizedPlanCode + `, 1, 9) = 'supergrok' OR substr(` + accountNormalizedPlanName + `, 1, 9) = 'supergrok')` accountFreePlanSignal = `(LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_code), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('free', 'grokfree', 'freetier', 'basic', 'grokbasic', 'xbasic') OR LOWER(REPLACE(REPLACE(REPLACE(REPLACE(TRIM(billing.plan_name), ' ', ''), '_', ''), '-', ''), '+', 'plus')) IN ('free', 'grokfree', 'freetier', 'basic', 'grokbasic', 'xbasic'))` accountPaidBillingSignals = `(` + accountPaidPlanSignal + ` OR billing.monthly_limit > 0 OR billing.on_demand_cap > 0 OR billing.on_demand_used > 0 OR billing.prepaid_balance > 0)` accountPaidBillingPredicate = `EXISTS (SELECT 1 FROM account_billing_snapshots billing WHERE billing.account_id = provider_accounts.id AND ` + accountPaidBillingSignals + `)` diff --git a/backend/internal/infra/persistence/relational/list_filter_repository_test.go b/backend/internal/infra/persistence/relational/list_filter_repository_test.go index 96299aaa4..c636a555a 100644 --- a/backend/internal/infra/persistence/relational/list_filter_repository_test.go +++ b/backend/internal/infra/persistence/relational/list_filter_repository_test.go @@ -315,6 +315,32 @@ func TestListFiltersRecognizesInferredFreeBilling(t *testing.T) { assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "unknown", Now: now}, 0) } +func TestListFiltersRecognizesSuperGrokPlusAsPaid(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "supergrok-plus.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + value := accountModel{IdentityKey: testIdentityKey("plus"), Provider: "grok_build", Name: "plus", SourceKey: "plus", Enabled: true, AuthStatus: "active", Priority: 1} + if err := database.db.WithContext(ctx).Create(&value).Error; err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&billingModel{ + AccountID: value.ID, PlanName: "SuperGrokPlus", IsUnifiedBillingUser: true, UsagePeriodType: "USAGE_PERIOD_TYPE_WEEKLY", SyncedAt: now, + }).Error; err != nil { + t.Fatal(err) + } + accounts := NewAccountRepository(database) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "paid", Now: now}, 1) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "free", Now: now}, 0) + assertAccountFilterCount(t, ctx, accounts, repository.AccountListFilter{QuotaType: "unknown", Now: now}, 0) +} + func assertAccountFilterCount(t *testing.T, ctx context.Context, accounts *AccountRepository, filter repository.AccountListFilter, expected int64) { t.Helper() _, total, err := accounts.List(ctx, repository.AccountListQuery{Page: repository.PageQuery{Limit: 20}, Filter: filter}) diff --git a/backend/internal/infra/provider/cli/billing_test.go b/backend/internal/infra/provider/cli/billing_test.go index 3c1881163..e6cfba66b 100644 --- a/backend/internal/infra/provider/cli/billing_test.go +++ b/backend/internal/infra/provider/cli/billing_test.go @@ -53,6 +53,14 @@ func TestParseBillingMatchesObservedBuildPayloads(t *testing.T) { if !credits.IsPaid() { t.Fatal("explicit SuperGrok tier must remain paid even when numeric limits are zero") } + + plus, err := parseBilling([]byte(`{"onDemandEnabled":false,"subscriptionTier":"SuperGrokPlus","config":{"creditUsagePercent":0,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-08-22T10:54:49.503515+00:00","end":"2026-08-29T10:54:49.503515+00:00"},"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"isUnifiedBillingUser":true,"prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD"}}`)) + if err != nil { + t.Fatal(err) + } + if plus.PlanName != "SuperGrokPlus" || !plus.IsPaid() { + t.Fatalf("SuperGrokPlus must be paid: %#v", plus) + } } func TestParseSubscriptionTierAndJWTFallback(t *testing.T) {