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/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/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/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 -} diff --git a/test/e2e/regression/billing_test.go b/test/e2e/regression/billing_test.go index c502d4b4ee..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" @@ -70,7 +73,6 @@ func (s *BillingRegressionTestSuite) SetupSuite() { }, Billing: billing.Config{ StripeKey: "sk_test_mock", - PlansPath: path.Join(testDataPath, "plans"), DefaultCurrency: "usd", AccountConfig: billing.AccountConfig{ AutoCreateWithOrg: true, @@ -82,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,6 +123,32 @@ 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)) + + // 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.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 diff --git a/test/e2e/testbench/testbench.go b/test/e2e/testbench/testbench.go index 9195da3b48..448d070141 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,26 @@ 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. + // 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, 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(), te.close(), stripeClose()) + } + } + if err := seedClient.Close(); err != nil { + return nil, errors.Join(err, te.close(), stripeClose()) + } + } + StartFrontier(logger, appConfig) // create ConnectRPC clients using the connect port