diff --git a/.werft/jobs/build/installer/installer.ts b/.werft/jobs/build/installer/installer.ts index 4b520d9f890e65..8cfdb282f02a2f 100644 --- a/.werft/jobs/build/installer/installer.ts +++ b/.werft/jobs/build/installer/installer.ts @@ -277,6 +277,7 @@ EOF`); exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.billInstancesAfter "2022-08-11T08:05:32.499Z"`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.defaultSpendingLimit.forUsers 500`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.defaultSpendingLimit.forTeams 0`, { slice: slice }) + exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.defaultSpendingLimit.minForUsersOnStripe 1000`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.creditsPerMinuteByWorkspaceClass['default'] 0.1666666667`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.creditsPerMinuteByWorkspaceClass['gitpodio-internal-xl'] 0.3333333333`, { slice: slice }) } diff --git a/components/usage/pkg/apiv1/usage_test.go b/components/usage/pkg/apiv1/usage_test.go index 33e2f4f8317843..9f4c4d6184d578 100644 --- a/components/usage/pkg/apiv1/usage_test.go +++ b/components/usage/pkg/apiv1/usage_test.go @@ -80,8 +80,9 @@ func newUsageService(t *testing.T, dbconn *gorm.DB) v1.UsageServiceClient { ) costCenterManager := db.NewCostCenterManager(dbconn, db.DefaultSpendingLimit{ - ForTeams: 0, - ForUsers: 500, + ForTeams: 0, + ForUsers: 500, + MinForUsersOnStripe: 1000, }) v1.RegisterUsageServiceServer(srv.GRPC(), NewUsageService(dbconn, DefaultWorkspacePricer, costCenterManager)) @@ -227,13 +228,13 @@ func TestGetAndSetCostCenter(t *testing.T) { conn := dbtest.ConnectForTests(t) costCenterUpdates := []*v1.CostCenter{ { - AttributionId: string(db.NewTeamAttributionID(uuid.New().String())), - SpendingLimit: 5000, - BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER, + AttributionId: string(db.NewUserAttributionID(uuid.New().String())), + SpendingLimit: 8000, + BillingStrategy: v1.CostCenter_BILLING_STRATEGY_STRIPE, }, { - AttributionId: string(db.NewTeamAttributionID(uuid.New().String())), - SpendingLimit: 8000, + AttributionId: string(db.NewUserAttributionID(uuid.New().String())), + SpendingLimit: 500, BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER, }, { @@ -243,7 +244,7 @@ func TestGetAndSetCostCenter(t *testing.T) { }, { AttributionId: string(db.NewTeamAttributionID(uuid.New().String())), - SpendingLimit: 5000, + SpendingLimit: 0, BillingStrategy: v1.CostCenter_BILLING_STRATEGY_OTHER, }, } diff --git a/components/usage/pkg/db/cost_center.go b/components/usage/pkg/db/cost_center.go index 67330ccb4186fd..fb4fabb27e798c 100644 --- a/components/usage/pkg/db/cost_center.go +++ b/components/usage/pkg/db/cost_center.go @@ -12,6 +12,8 @@ import ( "github.com/gitpod-io/gitpod/common-go/log" "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "gorm.io/gorm" ) @@ -39,8 +41,9 @@ func (d *CostCenter) TableName() string { } type DefaultSpendingLimit struct { - ForTeams int32 `json:"forTeams"` - ForUsers int32 `json:"forUsers"` + ForTeams int32 `json:"forTeams"` + ForUsers int32 `json:"forUsers"` + MinForUsersOnStripe int32 `json:"minForUsersOnStripe"` } func NewCostCenterManager(conn *gorm.DB, cfg DefaultSpendingLimit) *CostCenterManager { @@ -102,57 +105,112 @@ func getCostCenter(ctx context.Context, conn *gorm.DB, attributionId Attribution return costCenter, nil } -func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, costCenter CostCenter) (CostCenter, error) { +func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, newCC CostCenter) (CostCenter, error) { + if newCC.SpendingLimit < 0 { + return CostCenter{}, status.Errorf(codes.InvalidArgument, "Spending limit cannot be set below zero.") + } + attributionID := newCC.ID // retrieving the existing cost center to maintain the readonly values - existingCostCenter, err := c.GetOrCreateCostCenter(ctx, costCenter.ID) + existingCC, err := c.GetOrCreateCostCenter(ctx, newCC.ID) if err != nil { - return CostCenter{}, err + return CostCenter{}, status.Errorf(codes.NotFound, "cost center does not exist") } now := time.Now() // we always update the creationTime - costCenter.CreationTime = NewVarcharTime(now) + newCC.CreationTime = NewVarcharTime(now) // we don't allow setting the nextBillingTime from outside - costCenter.NextBillingTime = existingCostCenter.NextBillingTime - - // Do we have a billing strategy update? - if costCenter.BillingStrategy != existingCostCenter.BillingStrategy { - switch costCenter.BillingStrategy { - case CostCenter_Stripe: - // moving to stripe -> let's run a finalization - finalizationUsage, err := c.ComputeInvoiceUsageRecord(ctx, costCenter.ID) + newCC.NextBillingTime = existingCC.NextBillingTime + + isTeam := attributionID.IsEntity(AttributionEntity_Team) + isUser := attributionID.IsEntity(AttributionEntity_User) + + if isUser { + // New billing strategy is Stripe + if newCC.BillingStrategy == CostCenter_Stripe { + if newCC.SpendingLimit < c.cfg.MinForUsersOnStripe { + return CostCenter{}, status.Errorf(codes.FailedPrecondition, "individual users cannot lower their spending below %d", c.cfg.ForUsers) + } + } + + // Billing strategy remains unchanged (Other) + if newCC.BillingStrategy == CostCenter_Other && existingCC.BillingStrategy == CostCenter_Other { + if newCC.SpendingLimit != existingCC.SpendingLimit { + return CostCenter{}, status.Errorf(codes.FailedPrecondition, "individual users on a free plan cannot adjust their spending limit") + } + } + + // Downgrading from stripe + if existingCC.BillingStrategy == CostCenter_Stripe && newCC.BillingStrategy == CostCenter_Other { + newCC.SpendingLimit = c.cfg.ForUsers + // see you next month + newCC.NextBillingTime = NewVarcharTime(now.AddDate(0, 1, 0)) + } + + // Upgrading to Stripe + if existingCC.BillingStrategy == CostCenter_Other && newCC.BillingStrategy == CostCenter_Stripe { + err := c.upgradeToStripe(ctx, attributionID) if err != nil { return CostCenter{}, err } - if finalizationUsage != nil { - err = UpdateUsage(ctx, c.conn, *finalizationUsage) - if err != nil { - return CostCenter{}, err - } - } + // we don't manage stripe billing cycle - costCenter.NextBillingTime = VarcharTime{} - - case CostCenter_Other: - // cancelled from stripe reset the spending limit - if costCenter.ID.IsEntity(AttributionEntity_Team) { - costCenter.SpendingLimit = c.cfg.ForTeams - } else { - costCenter.SpendingLimit = c.cfg.ForUsers + newCC.NextBillingTime = VarcharTime{} + } + } else if isTeam { + // Billing strategy is Other, and it remains unchanged + if existingCC.BillingStrategy == CostCenter_Other && newCC.BillingStrategy == CostCenter_Other { + // It is impossible for a team without Stripe billing to change their spending limit + if newCC.SpendingLimit != c.cfg.ForTeams { + return CostCenter{}, status.Errorf(codes.FailedPrecondition, "teams without a subscription cannot change their spending limit") } + } + + // Downgrading from stripe + if existingCC.BillingStrategy == CostCenter_Stripe && newCC.BillingStrategy == CostCenter_Other { + newCC.SpendingLimit = c.cfg.ForTeams // see you next month - costCenter.NextBillingTime = NewVarcharTime(now.AddDate(0, 1, 0)) + newCC.NextBillingTime = NewVarcharTime(now.AddDate(0, 1, 0)) + } + + // Upgrading to Stripe + if existingCC.BillingStrategy == CostCenter_Other && newCC.BillingStrategy == CostCenter_Stripe { + err := c.upgradeToStripe(ctx, attributionID) + if err != nil { + return CostCenter{}, err + } + + // we don't manage stripe billing cycle + newCC.NextBillingTime = VarcharTime{} } + } else { + return CostCenter{}, status.Errorf(codes.InvalidArgument, "Unknown attribution entity %s", string(attributionID)) } - log.WithField("cost_center", costCenter).Info("saving cost center.") - db := c.conn.Save(&costCenter) + log.WithField("cost_center", newCC).Info("saving cost center.") + db := c.conn.Save(&newCC) if db.Error != nil { - return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", costCenter.ID, db.Error) + return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", newCC.ID, db.Error) } - return costCenter, nil + return newCC, nil +} + +func (c *CostCenterManager) upgradeToStripe(ctx context.Context, attributionID AttributionID) error { + // moving to stripe -> let's run a finalization + finalizationUsage, err := c.ComputeInvoiceUsageRecord(ctx, attributionID) + if err != nil { + return err + } + if finalizationUsage != nil { + err = UpdateUsage(ctx, c.conn, *finalizationUsage) + if err != nil { + return err + } + } + + return nil } func (c *CostCenterManager) ComputeInvoiceUsageRecord(ctx context.Context, attributionID AttributionID) (*Usage, error) { diff --git a/components/usage/pkg/db/cost_center_test.go b/components/usage/pkg/db/cost_center_test.go index 12bc6a891cf863..d94af9ee80e04e 100644 --- a/components/usage/pkg/db/cost_center_test.go +++ b/components/usage/pkg/db/cost_center_test.go @@ -13,6 +13,8 @@ import ( "github.com/gitpod-io/gitpod/usage/pkg/db/dbtest" "github.com/google/uuid" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "gorm.io/gorm" ) @@ -58,26 +60,143 @@ func TestCostCenterManager_GetOrCreateCostCenter(t *testing.T) { func TestCostCenterManager_UpdateCostCenter(t *testing.T) { conn := dbtest.ConnectForTests(t) - mnr := db.NewCostCenterManager(conn, db.DefaultSpendingLimit{ - ForTeams: 0, - ForUsers: 500, + limits := db.DefaultSpendingLimit{ + ForTeams: 0, + ForUsers: 500, + MinForUsersOnStripe: 1000, + } + + t.Run("prevents updates to negative spending limit", func(t *testing.T) { + mnr := db.NewCostCenterManager(conn, limits) + userAttributionID := db.NewUserAttributionID(uuid.New().String()) + teamAttributionID := db.NewTeamAttributionID(uuid.New().String()) + cleanUp(t, conn, userAttributionID, teamAttributionID) + + _, err := mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: userAttributionID, + BillingStrategy: db.CostCenter_Other, + SpendingLimit: -1, + }) + require.Error(t, err) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + + _, err = mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: -1, + }) + require.Error(t, err) + require.Equal(t, codes.InvalidArgument, status.Code(err)) }) - team := db.NewTeamAttributionID(uuid.New().String()) - cleanUp(t, conn, team) - teamCC, err := mnr.GetOrCreateCostCenter(context.Background(), team) - t.Cleanup(func() { - conn.Model(&db.CostCenter{}).Delete(teamCC) + + t.Run("individual user on Other billing strategy cannot change spending limit of 500", func(t *testing.T) { + mnr := db.NewCostCenterManager(conn, limits) + userAttributionID := db.NewUserAttributionID(uuid.New().String()) + cleanUp(t, conn, userAttributionID) + + _, err := mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: userAttributionID, + BillingStrategy: db.CostCenter_Other, + SpendingLimit: 501, + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + }) - require.NoError(t, err) - require.Equal(t, int32(0), teamCC.SpendingLimit) - teamCC.SpendingLimit = 2000 - teamCC, err = mnr.UpdateCostCenter(context.Background(), teamCC) - require.NoError(t, err) - t.Cleanup(func() { - conn.Model(&db.CostCenter{}).Delete(teamCC) + t.Run("individual user upgrading to stripe can set a limit of 1000 or more, but not less than 1000", func(t *testing.T) { + mnr := db.NewCostCenterManager(conn, limits) + userAttributionID := db.NewUserAttributionID(uuid.New().String()) + cleanUp(t, conn, userAttributionID) + + // Upgrading to Stripe requires spending limit + res, err := mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: userAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: 1000, + }) + require.NoError(t, err) + requireCostCenterEqual(t, db.CostCenter{ + ID: userAttributionID, + SpendingLimit: 1000, + BillingStrategy: db.CostCenter_Stripe, + }, res) + + // Try to lower the spending limit below configured limit + _, err = mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: userAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: 999, + }) + require.Error(t, err, "lowering spending limit below configured value is not allowed for user subscriptions") + + // Try to update the cost center to higher usage limit + res, err = mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: userAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: 1001, + }) + require.NoError(t, err) + requireCostCenterEqual(t, db.CostCenter{ + ID: userAttributionID, + SpendingLimit: 1001, + BillingStrategy: db.CostCenter_Stripe, + }, res) + }) + + t.Run("team on Other billing strategy get a spending limit of 0, and cannot change it", func(t *testing.T) { + mnr := db.NewCostCenterManager(conn, limits) + teamAttributionID := db.NewTeamAttributionID(uuid.New().String()) + cleanUp(t, conn, teamAttributionID) + + // Allows udpating cost center as long as spending limit remains as configured + res, err := mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Other, + SpendingLimit: limits.ForTeams, + }) + require.NoError(t, err) + requireCostCenterEqual(t, db.CostCenter{ + ID: teamAttributionID, + SpendingLimit: limits.ForTeams, + BillingStrategy: db.CostCenter_Other, + }, res) + + // Prevents updating when spending limit changes + _, err = mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Other, + SpendingLimit: 1, + }) + require.Error(t, err) + }) + + t.Run("team on Stripe billing strategy can set arbitrary positive spending limit", func(t *testing.T) { + mnr := db.NewCostCenterManager(conn, limits) + teamAttributionID := db.NewTeamAttributionID(uuid.New().String()) + cleanUp(t, conn, teamAttributionID) + + // Allows udpating cost center as long as spending limit remains as configured + res, err := mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: limits.ForTeams, + }) + require.NoError(t, err) + requireCostCenterEqual(t, db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: limits.ForTeams, + }, res) + + // Allows updating cost center to any positive value + _, err = mnr.UpdateCostCenter(context.Background(), db.CostCenter{ + ID: teamAttributionID, + BillingStrategy: db.CostCenter_Stripe, + SpendingLimit: 10, + }) + require.NoError(t, err) }) - require.Equal(t, int32(2000), teamCC.SpendingLimit) } func TestSaveCostCenterMovedToStripe(t *testing.T) { @@ -116,3 +235,12 @@ func cleanUp(t *testing.T, conn *gorm.DB, attributionIds ...db.AttributionID) { } }) } + +func requireCostCenterEqual(t *testing.T, expected, actual db.CostCenter) { + t.Helper() + + // ignore timestamps in comparsion + require.Equal(t, expected.ID, actual.ID) + require.EqualValues(t, expected.SpendingLimit, actual.SpendingLimit) + require.Equal(t, expected.BillingStrategy, actual.BillingStrategy) +} diff --git a/install/installer/pkg/components/usage/configmap.go b/install/installer/pkg/components/usage/configmap.go index a5b983c7eb8f87..cedd872dcc383e 100644 --- a/install/installer/pkg/components/usage/configmap.go +++ b/install/installer/pkg/components/usage/configmap.go @@ -29,8 +29,9 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { }, DefaultSpendingLimit: db.DefaultSpendingLimit{ // because we only want spending limits in SaaS, if not configured we go with a very high (i.e. no) spending limit - ForTeams: 1_000_000_000, - ForUsers: 1_000_000_000, + ForTeams: 1_000_000_000, + ForUsers: 1_000_000_000, + MinForUsersOnStripe: 0, }, } expConfig := getExperimentalConfig(ctx) diff --git a/install/installer/pkg/components/usage/configmap_test.go b/install/installer/pkg/components/usage/configmap_test.go index 1b40027e8817ba..a90d19cbe545d1 100644 --- a/install/installer/pkg/components/usage/configmap_test.go +++ b/install/installer/pkg/components/usage/configmap_test.go @@ -26,7 +26,8 @@ func TestConfigMap_ContainsSchedule(t *testing.T) { "stripeCredentialsFile": "stripe-secret/apikeys", "defaultSpendingLimit": { "forUsers": 1000000000, - "forTeams": 1000000000 + "forTeams": 1000000000, + "minForUsersOnStripe": 0 }, "server": { "services": {