From 2b165739ebafdc51159c254dc8e53b86bd81c697 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 11 Aug 2026 13:32:21 +0530 Subject: [PATCH 1/4] test(e2e): seed billing plans via the admin API instead of the boot loader --- core/event/service_test.go | 1 - test/e2e/regression/billing_test.go | 40 +++++++++- .../testdata/plans/subscription.credits.yaml | 73 ------------------- 3 files changed, 39 insertions(+), 75 deletions(-) delete mode 100644 test/e2e/regression/testdata/plans/subscription.credits.yaml diff --git a/core/event/service_test.go b/core/event/service_test.go index a3a46bfcab..ba7b988536 100644 --- a/core/event/service_test.go +++ b/core/event/service_test.go @@ -30,7 +30,6 @@ func mockService(t *testing.T) (*billing.Config, *mocks.CheckoutService, *mocks. StripeKey: "test_key", StripeAutoTax: false, StripeWebhookSecrets: nil, - PlansPath: "", DefaultCurrency: "USD", AccountConfig: billing.AccountConfig{AutoCreateWithOrg: true, DefaultPlan: "default_plan", DefaultOffline: false}, PlanChangeConfig: billing.PlanChangeConfig{}, diff --git a/test/e2e/regression/billing_test.go b/test/e2e/regression/billing_test.go index c502d4b4ee..38ef3b6279 100644 --- a/test/e2e/regression/billing_test.go +++ b/test/e2e/regression/billing_test.go @@ -70,7 +70,6 @@ func (s *BillingRegressionTestSuite) SetupSuite() { }, Billing: billing.Config{ StripeKey: "sk_test_mock", - PlansPath: path.Join(testDataPath, "plans"), DefaultCurrency: "usd", AccountConfig: billing.AccountConfig{ AutoCreateWithOrg: true, @@ -95,6 +94,45 @@ func (s *BillingRegressionTestSuite) SetupSuite() { s.Require().NoError(testbench.BootstrapOrganizations(ctx, s.testBench.Client, adminCookie)) s.Require().NoError(testbench.BootstrapProject(ctx, s.testBench.Client, adminCookie)) s.Require().NoError(testbench.BootstrapGroup(ctx, s.testBench.Client, adminCookie)) + + // Billing plans used to be seeded at boot from cfg.Billing.PlansPath. That + // loader is gone, so seed the fixtures these tests rely on through the admin + // API: the support_credits overdraft product and the enterprise_yearly plan. + ctxAdmin := testbench.ContextWithAuth(ctx, adminCookie) + _, err = s.testBench.Client.CreateProduct(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateProductRequest{ + Body: &frontierv1beta1.ProductRequestBody{ + Name: "support_credits", + Title: "Support Credits", + Description: "Support for enterprise help", + Behavior: "credits", + BehaviorConfig: &frontierv1beta1.Product_BehaviorConfig{CreditAmount: 100}, + Prices: []*frontierv1beta1.Price{ + {Name: "default", Amount: 20000, Currency: "usd"}, + }, + }, + })) + s.Require().NoError(err) + + _, err = s.testBench.AdminClient.CreatePlan(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreatePlanRequest{ + Body: &frontierv1beta1.PlanRequestBody{ + Name: "enterprise_yearly", + Title: "Enterprise Plan", + Description: "Enterprise Plan", + Interval: "year", + State: "active", + Products: []*frontierv1beta1.Product{ + { + Name: "enterprise_access", + Title: "Enterprise base access for year", + Description: "Base access to the platform", + Prices: []*frontierv1beta1.Price{ + {Name: "default", Interval: "year", Amount: 8000, Currency: "usd"}, + }, + }, + }, + }, + })) + s.Require().NoError(err) } func (s *BillingRegressionTestSuite) TearDownSuite() { diff --git a/test/e2e/regression/testdata/plans/subscription.credits.yaml b/test/e2e/regression/testdata/plans/subscription.credits.yaml deleted file mode 100644 index 47f9cfbacd..0000000000 --- a/test/e2e/regression/testdata/plans/subscription.credits.yaml +++ /dev/null @@ -1,73 +0,0 @@ -products: - - name: support_credits - title: Support Credits - description: Support for enterprise help - behavior: credits - config: - credit_amount: 100 - prices: - - name: default - amount: 20000 # in cents - currency: usd - - name: basic_access - title: Basic base access - description: Base access to the platform - behavior: per_seat - prices: - - name: monthly - interval: month - amount: 20000 - currency: usd - - name: starter_access - title: Starter base access - description: Base access to the platform - features: - - name: starter_feature_1 - - name: starter_feature_2 - prices: - - name: monthly - interval: month - amount: 1000 # $10 - currency: usd - - name: yearly - interval: year - amount: 5000 # $60 - currency: usd - - name: enterprise_access - title: Enterprise base access for year - description: Base access to the platform - prices: - - name: default - interval: year - amount: 8000 # $90 - currency: usd -plans: - - name: basic_monthly - title: Basic Monthly Plan - description: Basic Monthly Plan - interval: month - products: - - name: basic_access - state: active - - name: starter_yearly - title: Starter Plan - description: Starter Plan - interval: year - products: - - name: starter_access - state: active - - name: starter_monthly - title: Starter Plan - description: Starter Plan - interval: month - on_start_credits: 50 - products: - - name: starter_access - state: active - - name: enterprise_yearly - title: Enterprise Plan - description: Enterprise Plan - interval: year - products: - - name: enterprise_access - state: active \ No newline at end of file From 1c3cdbc8791c35d1df9f12a0bfe488fb8be16dad Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 11 Aug 2026 13:32:22 +0530 Subject: [PATCH 2/4] chore(server): remove the billing plans boot loader flow --- billing/config.go | 12 ++--- cmd/serve.go | 20 +------- config/sample.config.yaml | 3 -- internal/bootstrap/service.go | 26 ---------- internal/store/blob/plan_repository.go | 70 -------------------------- 5 files changed, 6 insertions(+), 125 deletions(-) delete mode 100644 internal/store/blob/plan_repository.go diff --git a/billing/config.go b/billing/config.go index d74816d982..c2b6b172f1 100644 --- a/billing/config.go +++ b/billing/config.go @@ -3,13 +3,11 @@ package billing import "time" type Config struct { - StripeKey string `yaml:"stripe_key" mapstructure:"stripe_key"` - StripeAutoTax bool `yaml:"stripe_auto_tax" mapstructure:"stripe_auto_tax"` - StripeWebhookSecrets []string `yaml:"stripe_webhook_secrets" mapstructure:"stripe_webhook_secrets"` - // PlansPath is a directory path where plans are defined - PlansPath string `yaml:"plans_path" mapstructure:"plans_path"` - DefaultCurrency string `yaml:"default_currency" mapstructure:"default_currency"` - PaymentMethodConfig []PaymentMethodConfig `yaml:"payment_method_config" mapstructure:"payment_method_config"` + StripeKey string `yaml:"stripe_key" mapstructure:"stripe_key"` + StripeAutoTax bool `yaml:"stripe_auto_tax" mapstructure:"stripe_auto_tax"` + StripeWebhookSecrets []string `yaml:"stripe_webhook_secrets" mapstructure:"stripe_webhook_secrets"` + DefaultCurrency string `yaml:"default_currency" mapstructure:"default_currency"` + PaymentMethodConfig []PaymentMethodConfig `yaml:"payment_method_config" mapstructure:"payment_method_config"` AccountConfig AccountConfig `yaml:"customer" mapstructure:"customer"` PlanChangeConfig PlanChangeConfig `yaml:"plan_change" mapstructure:"plan_change"` diff --git a/cmd/serve.go b/cmd/serve.go index 959ebace5e..3b4723bf90 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -150,13 +150,6 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error { } }() - // load billing plans - billingBlobFS, err := blob.NewStore(ctx, cfg.Billing.PlansPath, "") - if err != nil { - return err - } - billingPlanRepository := blob.NewPlanRepository(billingBlobFS) - promRegistry := prometheus.NewRegistry() promMetrics := prometheusmiddleware.NewClientMetrics( prometheusmiddleware.WithClientHandlingTimeHistogram(), @@ -182,7 +175,7 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error { return err } - deps, err := buildAPIDependencies(logger, cfg, dbClient, spiceDBClient, resourceBlobFS, billingPlanRepository) + deps, err := buildAPIDependencies(logger, cfg, dbClient, spiceDBClient, resourceBlobFS) if err != nil { return err } @@ -200,14 +193,6 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error { } logger.Info("migrated authz schema") - // apply billing plans - if cfg.Billing.PlansPath != "" { - if err = deps.BootstrapService.MigrateBillingPlans(ctx); err != nil { - return err - } - logger.Info("migrated billing plans") - } - // apply roles over nil org id // nil org is the default org of platform if err = deps.BootstrapService.MigrateRoles(ctx); err != nil { @@ -362,7 +347,6 @@ func buildAPIDependencies( dbc *db.Client, sdb *spicedb.SpiceDB, resourceBlobBucket blob.Bucket, - planBlobRepository *blob.PlanRepository, ) (api.Deps, error) { // Load additional traits from config file if specified traits, err := preference.LoadTraitsFromFile(cfg.App.AdditionalTraitsPath) @@ -609,8 +593,6 @@ func buildAPIDependencies( policyService, svUserRepo, cfg.App.PAT.DeniedPermissionsSet(), - planService, - planBlobRepository, svUserRepo, scUserCredRepo, serviceUserService, diff --git a/config/sample.config.yaml b/config/sample.config.yaml index 78c7720674..6746b02438 100644 --- a/config/sample.config.yaml +++ b/config/sample.config.yaml @@ -250,9 +250,6 @@ billing: # webhook secret to be used for validating stripe webhooks events # all the secrets are used to validate the events useful in case of key rotation stripe_webhook_secrets: [] - # path to plans spec file that will be used to create plans in billing engine - # e.g. file:///tmp/plans - plans_path: "" # default currency to be used for billing if not provided by the user # e.g. usd, inr, eur default_currency: "" diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index a4d9dd26b0..cb71143c6a 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -6,8 +6,6 @@ import ( "fmt" "log/slog" - "github.com/raystack/frontier/billing/plan" - azcore "github.com/authzed/spicedb/pkg/proto/core/v1" "github.com/raystack/frontier/core/namespace" @@ -50,14 +48,6 @@ type AuthzEngine interface { WriteSchema(ctx context.Context, schema string) error } -type BillingPlanRepository interface { - Get(ctx context.Context) (plan.File, error) -} - -type PlanService interface { - UpsertPlans(ctx context.Context, planFile plan.File) error -} - // PolicyService is policy.Service narrowed to what backfill needs. Goes through // Create so the SpiceDB rolebinding tuples land alongside the row. type PolicyService interface { @@ -100,9 +90,6 @@ type Service struct { suCreator ServiceUserCreator suCredStore ServiceUserCredentialStore suPromoter SuperUserPromoter - - planService PlanService - planLocalRepo BillingPlanRepository } func NewBootstrapService( @@ -117,8 +104,6 @@ func NewBootstrapService( policyService PolicyService, serviceuserRepo ServiceUserBackfiller, patDeniedPerms map[string]struct{}, - planService PlanService, - planLocalRepo BillingPlanRepository, suCreator ServiceUserCreator, suCredStore ServiceUserCredentialStore, suPromoter SuperUserPromoter, @@ -131,8 +116,6 @@ func NewBootstrapService( roleService: roleService, permissionService: actionService, authzEngine: authzEngine, - planService: planService, - planLocalRepo: planLocalRepo, relationService: relationService, policyService: policyService, serviceuserRepo: serviceuserRepo, @@ -436,12 +419,3 @@ func (s Service) migrateAZDefinitionsToDB(ctx context.Context, azDefinitions []* } return nil } - -func (s Service) MigrateBillingPlans(ctx context.Context) error { - localPlans, err := s.planLocalRepo.Get(ctx) - if err != nil { - return err - } - - return s.planService.UpsertPlans(ctx, localPlans) -} diff --git a/internal/store/blob/plan_repository.go b/internal/store/blob/plan_repository.go deleted file mode 100644 index 09648dea2a..0000000000 --- a/internal/store/blob/plan_repository.go +++ /dev/null @@ -1,70 +0,0 @@ -package blob - -import ( - "context" - "fmt" - "io" - "strings" - - "github.com/raystack/frontier/billing/plan" - "github.com/raystack/frontier/billing/product" - - "gocloud.dev/blob" - "gopkg.in/yaml.v3" -) - -type PlanRepository struct { - bucket Bucket -} - -func NewPlanRepository(b Bucket) *PlanRepository { - return &PlanRepository{bucket: b} -} - -// Get returns the plans from the bucket -func (s *PlanRepository) Get(ctx context.Context) (plan.File, error) { - var definitions []plan.File - - // iterate over bucket files, only read .yml & .yaml files - it := s.bucket.List(&blob.ListOptions{}) - for { - obj, err := it.Next(ctx) - if err != nil { - if err == io.EOF { - break - } - return plan.File{}, err - } - - if obj.IsDir { - continue - } - if !(strings.HasSuffix(obj.Key, ".yaml") || strings.HasSuffix(obj.Key, ".yml")) { - continue - } - fileBytes, err := s.bucket.ReadAll(ctx, obj.Key) - if err != nil { - return plan.File{}, fmt.Errorf("%s: %s", "error in reading bucket object", err.Error()) - } - - var def plan.File - if err := yaml.Unmarshal(fileBytes, &def); err != nil { - return plan.File{}, fmt.Errorf("get: yaml.Unmarshal: %s: %w", obj.Key, err) - } - definitions = append(definitions, def) - } - - var allPlans []plan.Plan - var allProducts []product.Product - var allFeatures []product.Feature - for _, definition := range definitions { - allPlans = append(allPlans, definition.Plans...) - allProducts = append(allProducts, definition.Products...) - allFeatures = append(allFeatures, definition.Features...) - } - return plan.File{ - Plans: allPlans, - Products: allProducts, - Features: allFeatures, - }, nil -} From caa4a3014d180a53a057a2b05db178745f1cdb26 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 11 Aug 2026 14:45:39 +0530 Subject: [PATCH 3/4] test(e2e): seed the credit overdraft product before the server starts --- test/e2e/regression/billing_test.go | 52 +++++++++++++++++++---------- test/e2e/testbench/testbench.go | 26 ++++++++++++++- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/test/e2e/regression/billing_test.go b/test/e2e/regression/billing_test.go index 38ef3b6279..5560e66e6a 100644 --- a/test/e2e/regression/billing_test.go +++ b/test/e2e/regression/billing_test.go @@ -20,8 +20,11 @@ import ( "google.golang.org/protobuf/types/known/structpb" "github.com/raystack/frontier/billing" + "github.com/raystack/frontier/billing/product" "github.com/raystack/frontier/core/authenticate" testusers "github.com/raystack/frontier/core/authenticate/test_users" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" "github.com/raystack/frontier/pkg/server" frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" @@ -81,7 +84,33 @@ func (s *BillingRegressionTestSuite) SetupSuite() { }, } - s.testBench, err = testbench.Init(appConfig) + // support_credits is the credit_overdraft_product the billing service + // resolves during boot (invoice.Init). The boot loader used to seed it from + // cfg.Billing.PlansPath; that loader is gone, so seed it straight into the + // database before the server starts, so the boot sequence can find it. + seedOverdraftProduct := func(ctx context.Context, dbc *db.Client) error { + prod, err := postgres.NewBillingProductRepository(dbc).Create(ctx, product.Product{ + ID: uuid.New().String(), + Name: "support_credits", + Title: "Support Credits", + Description: "Support for enterprise help", + Behavior: product.CreditBehavior, + Config: product.BehaviorConfig{CreditAmount: 100}, + State: "active", + }) + if err != nil { + return err + } + _, err = postgres.NewBillingPriceRepository(dbc).Create(ctx, product.Price{ + Name: "default", + ProductID: prod.ID, + Amount: 20000, + Currency: "usd", + }) + return err + } + + s.testBench, err = testbench.Init(appConfig, seedOverdraftProduct) s.Require().NoError(err) ctx := context.Background() @@ -95,24 +124,11 @@ func (s *BillingRegressionTestSuite) SetupSuite() { s.Require().NoError(testbench.BootstrapProject(ctx, s.testBench.Client, adminCookie)) s.Require().NoError(testbench.BootstrapGroup(ctx, s.testBench.Client, adminCookie)) - // Billing plans used to be seeded at boot from cfg.Billing.PlansPath. That - // loader is gone, so seed the fixtures these tests rely on through the admin - // API: the support_credits overdraft product and the enterprise_yearly plan. + // The enterprise_yearly plan used to be seeded at boot from + // cfg.Billing.PlansPath. That loader is gone, so create it through the admin + // API once the server is up (a checkout test uses it). The overdraft product + // was already seeded before boot above. ctxAdmin := testbench.ContextWithAuth(ctx, adminCookie) - _, err = s.testBench.Client.CreateProduct(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateProductRequest{ - Body: &frontierv1beta1.ProductRequestBody{ - Name: "support_credits", - Title: "Support Credits", - Description: "Support for enterprise help", - Behavior: "credits", - BehaviorConfig: &frontierv1beta1.Product_BehaviorConfig{CreditAmount: 100}, - Prices: []*frontierv1beta1.Price{ - {Name: "default", Amount: 20000, Currency: "usd"}, - }, - }, - })) - s.Require().NoError(err) - _, err = s.testBench.AdminClient.CreatePlan(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreatePlanRequest{ Body: &frontierv1beta1.PlanRequestBody{ Name: "enterprise_yearly", diff --git a/test/e2e/testbench/testbench.go b/test/e2e/testbench/testbench.go index 9195da3b48..2890cd99a8 100644 --- a/test/e2e/testbench/testbench.go +++ b/test/e2e/testbench/testbench.go @@ -41,7 +41,14 @@ type TestBench struct { close func() error } -func Init(appConfig *config.Frontier) (*TestBench, error) { +// PreStartSeeder writes to the freshly migrated database before the frontier +// server starts. Use it to seed rows the server needs at boot, for example a +// billing product referenced by credit_overdraft_product, which the removed +// boot loader used to create. It runs after migrations and before the server +// starts, so the boot sequence can read what it seeds. +type PreStartSeeder func(ctx context.Context, dbc *db.Client) error + +func Init(appConfig *config.Frontier, preStartSeeders ...PreStartSeeder) (*TestBench, error) { var ( err error logger = logger.InitLogger(appConfig.Log) @@ -114,6 +121,23 @@ func Init(appConfig *config.Frontier) (*TestBench, error) { return errors.Join(err1, err2) } + // seed rows the server needs at boot before it starts. The database is + // migrated but the server is not up yet, so a seeder can write directly. + if len(preStartSeeders) > 0 { + seedClient, err := db.New(appConfig.DB) + if err != nil { + return nil, err + } + for _, seed := range preStartSeeders { + if err := seed(context.Background(), seedClient); err != nil { + return nil, errors.Join(err, seedClient.Close()) + } + } + if err := seedClient.Close(); err != nil { + return nil, err + } + } + StartFrontier(logger, appConfig) // create ConnectRPC clients using the connect port From 26001fb14d1dec5e24862173140b0882f7780803 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 11 Aug 2026 15:33:48 +0530 Subject: [PATCH 4/4] test(e2e): tear down containers when a pre-start seeder fails --- docs/content/docs/reference/billing-configurations.mdx | 1 - docs/content/docs/reference/configurations.mdx | 3 --- test/e2e/testbench/testbench.go | 9 ++++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/reference/billing-configurations.mdx b/docs/content/docs/reference/billing-configurations.mdx index ec4ced8be7..937d0bd1e4 100644 --- a/docs/content/docs/reference/billing-configurations.mdx +++ b/docs/content/docs/reference/billing-configurations.mdx @@ -21,7 +21,6 @@ This document provides instructions on how to configure the billing settings for | **billing.stripe_key** | Developer key generated on Stripe | sk_test_abcdefghijklmnopqrstuvwxyz | Yes | | **billing.stripe_auto_tax** | Set to true if you want Stripe to automatically apply tax on the invoices as per the customer's location | false | No (default: false) | | **billing.stripe_webhook_secrets** | Webhook secrets to be used for validating stripe webhooks events | [] | No | -| **billing.plans_path** | Path to a folder which has yaml files describing the products and plans that need to be created on the billing engine (Stripe). The plans and products in these files will be automatically created on Stripe as part of migration during application startup | "file:///tmp/plans" | No (but recommended) | | **billing.default_plan** | Name of the plan that should be used subscribed automatically when the org is created. It also automatically creates an empty billing account under the org.
**Note: The plan name provided here should exist in the billing engine.** | "standard_plan" | No | | **billing.default_currency** | Default currency to be used for billing if not provided by the user | "USD" | No (but recommended) | | **billing.plan_change.proration_behavior** | Proration behaviour to be used when a subscription is changed, or its quantity is updated. Can be one of "create_prorations", "always_invoice" or "none" | "create_prorations" | No (default: create_prorations) | diff --git a/docs/content/docs/reference/configurations.mdx b/docs/content/docs/reference/configurations.mdx index fbfabcf5d8..d4ca0a12d4 100644 --- a/docs/content/docs/reference/configurations.mdx +++ b/docs/content/docs/reference/configurations.mdx @@ -181,9 +181,6 @@ billing: # webhook secret to be used for validating stripe webhooks events # all the secrets are used to validate the events useful in case of key rotation stripe_webhook_secrets: [] - # path to plans spec file that will be used to create plans in billing engine - # e.g. file:///tmp/plans - plans_path: "" # default currency to be used for billing if not provided by the user # e.g. usd, inr, eur default_currency: "" diff --git a/test/e2e/testbench/testbench.go b/test/e2e/testbench/testbench.go index 2890cd99a8..448d070141 100644 --- a/test/e2e/testbench/testbench.go +++ b/test/e2e/testbench/testbench.go @@ -123,18 +123,21 @@ func Init(appConfig *config.Frontier, preStartSeeders ...PreStartSeeder) (*TestB // seed rows the server needs at boot before it starts. The database is // migrated but the server is not up yet, so a seeder can write directly. + // On any error tear the containers down with te.close and stripeClose so a + // failed seeder does not leak them. Use te.close, not te.Close: the server + // is not started, so te.Close would send SIGINT to the test process itself. if len(preStartSeeders) > 0 { seedClient, err := db.New(appConfig.DB) if err != nil { - return nil, err + return nil, errors.Join(err, te.close(), stripeClose()) } for _, seed := range preStartSeeders { if err := seed(context.Background(), seedClient); err != nil { - return nil, errors.Join(err, seedClient.Close()) + return nil, errors.Join(err, seedClient.Close(), te.close(), stripeClose()) } } if err := seedClient.Close(); err != nil { - return nil, err + return nil, errors.Join(err, te.close(), stripeClose()) } }