Преглед изворни кода

add ironplans integration for billing

Alexander Belanger пре 4 година
родитељ
комит
a582f50262
32 измењених фајлова са 1205 додато и 43 уклоњено
  1. 5 5
      api/server/authz/policy_test.go
  2. 2 2
      api/server/authz/project_test.go
  3. 36 0
      api/server/handlers/billing/billing_ce.go
  4. 28 0
      api/server/handlers/billing/billing_ee.go
  5. 28 9
      api/server/handlers/project/create.go
  6. 8 0
      api/server/handlers/project/delete_role.go
  7. 1 1
      api/server/handlers/project/get_test.go
  8. 2 2
      api/server/handlers/project/list_test.go
  9. 8 0
      api/server/handlers/project/update_role.go
  10. 0 2
      api/server/router/invite.go
  11. 54 0
      api/server/router/project.go
  12. 2 0
      api/server/shared/apitest/config.go
  13. 4 0
      api/server/shared/config/config.go
  14. 3 0
      api/server/shared/config/env/envconfs.go
  15. 7 0
      api/server/shared/config/loader/init_ce.go
  16. 36 0
      api/server/shared/config/loader/init_ee.go
  17. 22 20
      api/server/shared/config/loader/loader.go
  18. 4 0
      api/types/project.go
  19. 65 0
      ee/api/server/handlers/billing/get_token.go
  20. 77 0
      ee/api/server/handlers/billing/webhook.go
  21. 23 2
      ee/api/server/handlers/invite/accept.go
  22. 412 0
      ee/billing/ironplans.go
  23. 54 0
      ee/billing/types.go
  24. 13 0
      ee/models/project_billing.go
  25. 15 0
      ee/models/user_billing.go
  26. 46 0
      ee/repository/gorm/project_billing.go
  27. 29 0
      ee/repository/gorm/repository.go
  28. 116 0
      ee/repository/gorm/user_billing.go
  29. 11 0
      ee/repository/project_billing.go
  30. 8 0
      ee/repository/repository.go
  31. 11 0
      ee/repository/user_billing.go
  32. 75 0
      internal/billing/billing.go

+ 5 - 5
api/server/authz/policy_test.go

@@ -28,7 +28,7 @@ func TestPolicyMiddlewareSuccessfulProjectCluster(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -76,7 +76,7 @@ func TestPolicyMiddlewareSuccessfulApplication(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -141,7 +141,7 @@ func TestPolicyMiddlewareInvalidPermissions(t *testing.T) {
 	}, false, true)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -175,7 +175,7 @@ func TestPolicyMiddlewareFailInvalidLoader(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -208,7 +208,7 @@ func TestPolicyMiddlewareFailBadParam(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 2 - 2
api/server/authz/project_test.go

@@ -18,7 +18,7 @@ func TestProjectMiddlewareSuccessful(t *testing.T) {
 	config, handler, next := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -46,7 +46,7 @@ func TestProjectMiddlewareFailedRead(t *testing.T) {
 	config, _, _ := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 36 - 0
api/server/handlers/billing/billing_ce.go

@@ -0,0 +1,36 @@
+// +build !ee
+
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type BillingGetTokenHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewBillingGetTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_get_token")
+}
+
+type BillingWebhookHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_webhook")
+}

+ 28 - 0
api/server/handlers/billing/billing_ee.go

@@ -0,0 +1,28 @@
+// +build ee
+
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"github.com/porter-dev/porter/ee/api/server/handlers/billing"
+)
+
+var NewBillingGetTokenHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler
+
+var NewBillingWebhookHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler
+
+func init() {
+	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
+	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
+}

+ 28 - 9
api/server/handlers/project/create.go

@@ -44,33 +44,52 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	var err error
-	proj, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, role, err := CreateProjectWithUser(p.Repo().Project(), proj, user)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	p.WriteResult(w, r, proj.ToProjectType())
+
+	// add project to billing team
+	teamID, err := p.Config().BillingManager.CreateTeam(proj)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
+
+	if teamID != "" {
+		err = p.Config().BillingManager.AddUserToTeam(teamID, user, role)
+
+		if err != nil {
+			// we do not write error response, since setting up billing error can be
+			// resolved later and may not be fatal
+			p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+
 	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
 		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
 	}))
-
-	p.WriteResult(w, r, proj.ToProjectType())
 }
 
 func CreateProjectWithUser(
 	projectRepo repository.ProjectRepository,
 	proj *models.Project,
 	user *models.User,
-) (*models.Project, error) {
+) (*models.Project, *models.Role, error) {
 	proj, err := projectRepo.CreateProject(proj)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// create a new Role with the user as the admin
-	_, err = projectRepo.CreateProjectRole(proj, &models.Role{
+	role, err := projectRepo.CreateProjectRole(proj, &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
@@ -79,15 +98,15 @@ func CreateProjectWithUser(
 	})
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// read the project again to get the model with the role attached
 	proj, err = projectRepo.ReadProject(proj.ID)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
-	return proj, nil
+	return proj, role, nil
 }

+ 8 - 0
api/server/handlers/project/delete_role.go

@@ -54,4 +54,12 @@ func (p *RoleDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
+
+	err = p.Config().BillingManager.RemoveUserFromTeam(role)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
 }

+ 1 - 1
api/server/handlers/project/get_test.go

@@ -14,7 +14,7 @@ func TestGetProjectSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 2 - 2
api/server/handlers/project/list_test.go

@@ -15,7 +15,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj1, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj1, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -23,7 +23,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	proj2, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj2, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project-2",
 	}, user)
 

+ 8 - 0
api/server/handlers/project/update_role.go

@@ -57,4 +57,12 @@ func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
+
+	err = p.Config().BillingManager.UpdateUserInTeam(role)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
 }

+ 0 - 2
api/server/router/invite.go

@@ -126,8 +126,6 @@ func getInviteRoutes(
 				types.UserScope,
 			},
 			ShouldRedirect: true,
-			CheckUsage:     true,
-			UsageMetric:    types.Users,
 		},
 	)
 

+ 54 - 0
api/server/router/project.go

@@ -2,6 +2,7 @@ package router
 
 import (
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/project"
@@ -164,6 +165,59 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
+	getBillingTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	getBillingTokenHandler := billing.NewBillingGetTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingTokenEndpoint,
+		Handler:  getBillingTokenHandler,
+		Router:   r,
+	})
+
+	// GET /api/billing_webhook -> billing.NewBillingWebhookHandler
+	getBillingWebhookEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/billing_webhook",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	getBillingWebhookHandler := billing.NewBillingWebhookHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingWebhookEndpoint,
+		Handler:  getBillingWebhookHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters -> cluster.NewClusterListHandler
 	listClusterEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 2 - 0
api/server/shared/apitest/config.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/test"
 )
@@ -57,6 +58,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 		TokenConf:       tokenConf,
 		UserNotifier:    notifier,
 		AnalyticsClient: analytics.InitializeAnalyticsSegmentClient("", l),
+		BillingManager:  &billing.NoopBillingManager{},
 	}, nil
 }
 

+ 4 - 0
api/server/shared/config/config.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
@@ -85,6 +86,9 @@ type Config struct {
 
 	// AnalyticsClient if Segment analytics reporting is enabled on the API instance
 	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	// BillingManager manages billing for Porter instances with billing enabled
+	BillingManager billing.BillingManager
 }
 
 type ConfigLoader interface {

+ 3 - 0
api/server/shared/config/env/envconfs.go

@@ -49,6 +49,9 @@ type ServerConf struct {
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`
 	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 
+	IronPlansAPIKey    string `env:"IRON_PLANS_API_KEY"`
+	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
+
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`

+ 7 - 0
api/server/shared/config/loader/init_ce.go

@@ -0,0 +1,7 @@
+// +build !ee
+
+package loader
+
+func init() {
+	sharedInit()
+}

+ 36 - 0
api/server/shared/config/loader/init_ee.go

@@ -0,0 +1,36 @@
+// +build ee
+
+package loader
+
+import (
+	eeBilling "github.com/porter-dev/porter/ee/billing"
+	"github.com/porter-dev/porter/ee/models"
+	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
+	"github.com/porter-dev/porter/internal/billing"
+)
+
+func init() {
+	sharedInit()
+
+	InstanceDB.AutoMigrate(
+		&models.ProjectBilling{},
+		&models.UserBilling{},
+	)
+
+	var key [32]byte
+
+	for i, b := range []byte(InstanceEnvConf.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	eeRepo := eeGorm.NewEERepository(InstanceDB, &key)
+
+	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
+		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
+		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+
+		InstanceBillingManager = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+	} else {
+		InstanceBillingManager = &billing.NoopBillingManager{}
+	}
+}

+ 22 - 20
api/server/shared/config/loader/loader.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
@@ -23,8 +24,14 @@ import (
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	lr "github.com/porter-dev/porter/internal/logger"
+
+	pgorm "gorm.io/gorm"
 )
 
+var InstanceBillingManager billing.BillingManager
+var InstanceEnvConf *EnvConf
+var InstanceDB *pgorm.DB
+
 type EnvConfigLoader struct {
 	version string
 }
@@ -33,33 +40,28 @@ func NewEnvLoader(version string) config.ConfigLoader {
 	return &EnvConfigLoader{version}
 }
 
-func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
-	envConf, err := FromEnv()
-
-	if err != nil {
-		return nil, err
-	}
+func sharedInit() {
+	InstanceEnvConf, _ = FromEnv()
+	InstanceDB, _ = adapter.New(InstanceEnvConf.DBConf)
+	InstanceBillingManager = &billing.NoopBillingManager{}
+}
 
+func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
+	envConf := InstanceEnvConf
 	sc := envConf.ServerConf
 
 	res = &config.Config{
-		Logger:     lr.NewConsole(sc.Debug),
-		ServerConf: sc,
-		DBConf:     envConf.DBConf,
-		RedisConf:  envConf.RedisConf,
+		Logger:         lr.NewConsole(sc.Debug),
+		ServerConf:     sc,
+		DBConf:         envConf.DBConf,
+		RedisConf:      envConf.RedisConf,
+		BillingManager: InstanceBillingManager,
 	}
 
 	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
+	res.DB = InstanceDB
 
-	db, err := adapter.New(envConf.DBConf)
-
-	if err != nil {
-		return nil, err
-	}
-
-	res.DB = db
-
-	err = gorm.AutoMigrate(db)
+	err = gorm.AutoMigrate(InstanceDB)
 
 	if err != nil {
 		return nil, err
@@ -71,7 +73,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		key[i] = b
 	}
 
-	res.Repo = gorm.NewRepository(db, &key)
+	res.Repo = gorm.NewRepository(InstanceDB, &key)
 
 	// create the session store
 	res.Store, err = sessionstore.NewStore(

+ 4 - 0
api/types/project.go

@@ -61,3 +61,7 @@ type DeleteRoleRequest struct {
 type DeleteRoleResponse struct {
 	*Role
 }
+
+type GetBillingTokenResponse struct {
+	Token string `json:"token"`
+}

+ 65 - 0
ee/api/server/handlers/billing/get_token.go

@@ -0,0 +1,65 @@
+package billing
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type BillingGetTokenHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewBillingGetTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return &BillingGetTokenHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *BillingGetTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// we double-check that the user is an admin the project
+	roles, err := c.Repo().Project().ListProjectRoles(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, role := range roles {
+		if role.UserID != 0 && role.UserID == user.ID {
+			if role.Kind != types.RoleAdmin {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("user %d is not an admin in project %d", user.ID, proj.ID),
+				))
+
+				return
+			}
+		}
+	}
+
+	token, err := c.Config().BillingManager.GetIDToken(proj.ID, user)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetBillingTokenResponse{
+		Token: token,
+	})
+}

+ 77 - 0
ee/api/server/handlers/billing/webhook.go

@@ -0,0 +1,77 @@
+package billing
+
+import (
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"gorm.io/gorm"
+)
+
+type BillingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return &BillingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("x-signature")
+
+	if !c.Config().BillingManager.VerifySignature(signature, payload) {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+			fmt.Errorf("could not verify signature for billing webhook"),
+		))
+
+		return
+	}
+
+	// parse usage and update project
+	newUsage, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// update the project's usage
+	_, err = c.Repo().ProjectUsage().ReadProjectUsage(newUsage.ProjectID)
+	notFound := errors.Is(err, gorm.ErrRecordNotFound)
+
+	if !notFound && err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if notFound {
+		_, err = c.Repo().ProjectUsage().CreateProjectUsage(newUsage)
+	} else {
+		_, err = c.Repo().ProjectUsage().UpdateProjectUsage(newUsage)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 23 - 2
ee/api/server/handlers/invite/accept.go

@@ -83,13 +83,15 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		kind = models.RoleDeveloper
 	}
 
-	if _, err = c.Repo().Project().CreateProjectRole(proj, &models.Role{
+	role := &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
 			Kind:      types.RoleKind(kind),
 		},
-	}); err != nil {
+	}
+
+	if role, err = c.Repo().Project().CreateProjectRole(proj, role); err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -102,5 +104,24 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// add project to billing team
+	teamID, err := c.Config().BillingManager.GetTeamID(proj)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
+
+	if teamID != "" {
+		err = c.Config().BillingManager.AddUserToTeam(teamID, user, role)
+
+		if err != nil {
+			// we do not write error response, since setting up billing error can be
+			// resolved later and may not be fatal
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+
 	http.Redirect(w, r, "/dashboard", 302)
 }

+ 412 - 0
ee/billing/ironplans.go

@@ -0,0 +1,412 @@
+// +build ee
+
+package billing
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+
+	cemodels "github.com/porter-dev/porter/internal/models"
+)
+
+// Client contains an API client for a Bind DNS server wrapped
+// with a lightweight API
+type Client struct {
+	apiKey    string
+	serverURL string
+	repo      repository.EERepository
+
+	httpClient *http.Client
+}
+
+// NewClient creates a new billing API client
+func NewClient(serverURL, apiKey string, repo repository.EERepository) *Client {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	return &Client{apiKey, serverURL, repo, httpClient}
+}
+
+// CreateTeamRequest creates a new team for billing
+type CreateTeamRequest struct {
+	Name string `json:"name"`
+}
+
+type AddTeammateRequest struct {
+	Role     RoleEnum `json:"role"`
+	Email    string   `json:"email"`
+	SourceID string   `json:"source_id"`
+	TeamID   string   `json:"team_id"`
+}
+
+type UpdateTeammateRequest struct {
+	Role RoleEnum `json:"role"`
+}
+
+type CreateIDTokenRequest struct {
+	Email  string `json:"customer_email"`
+	UserID string `json:"customer_source_id"`
+}
+
+type CreateIDTokenResponse struct {
+	Token string `json:"token"`
+}
+
+func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
+	resp := &Team{}
+	err := c.postRequest("/teams/v1", &CreateTeamRequest{
+		Name: proj.Name,
+	}, resp)
+
+	if err != nil {
+		return "", err
+	}
+
+	_, err = c.repo.ProjectBilling().CreateProjectBilling(&models.ProjectBilling{
+		ProjectID:     proj.ID,
+		BillingTeamID: resp.ID,
+	})
+
+	if err != nil {
+		return "", err
+	}
+
+	return resp.ID, err
+}
+
+func (c *Client) GetTeamID(proj *cemodels.Project) (teamID string, err error) {
+	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
+
+	if err != nil {
+		return "", err
+	}
+
+	return projBilling.BillingTeamID, nil
+}
+
+func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
+	roleEnum := RoleEnumMember
+
+	// if user's role is admin, add them to the team as an owner
+	if role.Kind == types.RoleAdmin {
+		roleEnum = RoleEnumOwner
+	}
+
+	req := &AddTeammateRequest{
+		TeamID:   teamID,
+		Role:     roleEnum,
+		Email:    user.Email,
+		SourceID: fmt.Sprintf("%d-%d", role.ProjectID, user.ID),
+	}
+
+	resp := &Teammate{}
+
+	err := c.postRequest("/team_memberships/v1", req, resp)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
+		ProjectID:  role.ProjectID,
+		UserID:     user.ID,
+		TeammateID: resp.ID,
+		Token:      []byte(""),
+	})
+
+	return err
+}
+
+func (c *Client) UpdateUserInTeam(role *cemodels.Role) error {
+	// get the user billing information to get the membership id
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	roleEnum := RoleEnumMember
+
+	// if user's role is admin, add them to the team as an owner
+	if role.Kind == types.RoleAdmin {
+		roleEnum = RoleEnumOwner
+	}
+
+	req := &UpdateTeammateRequest{
+		Role: roleEnum,
+	}
+
+	resp := &Teammate{}
+
+	return c.putRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), req, resp)
+}
+
+func (c *Client) RemoveUserFromTeam(role *cemodels.Role) error {
+	// get the user billing information to get the membership id
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	return c.deleteRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), nil, nil)
+}
+
+// GetIDToken gets an id token for a user in a project, creating the ID token if necessary
+func (c *Client) GetIDToken(projectID uint, user *cemodels.User) (token string, err error) {
+	// attempt to read the user billing data from the
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(projectID, user.ID)
+	notFound := errors.Is(err, gorm.ErrRecordNotFound)
+
+	if !notFound && err != nil {
+		return "", err
+	}
+
+	if !notFound {
+		token = string(userBilling.Token)
+
+		if token != "" {
+			// check if the JWT token has expired
+			isTokExpired := isExpired(token)
+
+			// if JWT token has not expired, return the token
+			if !isTokExpired {
+				return token, nil
+			}
+		}
+	}
+
+	req := &CreateIDTokenRequest{
+		Email:  user.Email,
+		UserID: fmt.Sprintf("%d-%d", projectID, user.ID),
+	}
+
+	resp := &CreateIDTokenResponse{}
+
+	err = c.postRequest("/customers/v1/token", req, resp)
+
+	if err != nil {
+		return "", err
+	}
+
+	token = resp.Token
+
+	if notFound {
+		_, err := c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
+			ProjectID: projectID,
+			UserID:    user.ID,
+			Token:     []byte(token),
+		})
+
+		if err != nil {
+			return "", err
+		}
+	} else {
+		_, err := c.repo.UserBilling().UpdateUserBilling(&models.UserBilling{
+			Model: &gorm.Model{
+				ID: userBilling.ID,
+			},
+			ProjectID: projectID,
+			UserID:    user.ID,
+			Token:     []byte(token),
+		})
+
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return token, nil
+}
+
+// VerifySignature verifies a webhook signature based on hmac protocal
+// https://docs.ironplans.com/webhook-events/webhook-events
+func (c *Client) VerifySignature(signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	_, err := hex.Decode(actual, []byte(signature[7:]))
+
+	if err != nil {
+		return false
+	}
+
+	computed := hmac.New(sha256.New, []byte(c.apiKey))
+	_, err = computed.Write(body)
+
+	if err != nil {
+		return false
+	}
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}
+
+func (c *Client) postRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("POST", path, data, dst)
+}
+
+func (c *Client) putRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("PUT", path, data, dst)
+}
+
+func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("DELETE", path, data, dst)
+}
+
+func (c *Client) writeRequest(method, path string, data interface{}, dst interface{}) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	var strData []byte
+
+	if data != nil {
+		strData, err = json.Marshal(data)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	req, err := http.NewRequest(
+		method,
+		reqURL.String(),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	if dst != nil {
+		return json.NewDecoder(res.Body).Decode(dst)
+	}
+
+	return nil
+}
+
+type SubscriptionWebhookRequest struct {
+	TeamID string `json:"team_id"`
+	Plan   Plan   `json:"plan"`
+}
+
+const (
+	FeatureSlugCPU      string = "cpu"
+	FeatureSlugMemory   string = "memory"
+	FeatureSlugClusters string = "clusters"
+	FeatureSlugUsers    string = "users"
+)
+
+func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.ProjectUsage, error) {
+	subscription := &SubscriptionWebhookRequest{}
+
+	err := json.Unmarshal(payload, subscription)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// get the project id linked to that team
+	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByTeamID(subscription.TeamID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	usage := &cemodels.ProjectUsage{
+		ProjectID: projBilling.ProjectID,
+	}
+
+	for _, feature := range subscription.Plan.Features {
+		// look for slug of "cpus" and "memory"
+		maxLimit := uint(feature.MaxLimit)
+		switch feature.Slug {
+		case FeatureSlugCPU:
+			usage.ResourceCPU = maxLimit
+		case FeatureSlugMemory:
+			usage.ResourceMemory = 1000 * maxLimit
+		case FeatureSlugClusters:
+			usage.Clusters = maxLimit
+		case FeatureSlugUsers:
+			usage.Users = maxLimit
+		}
+	}
+
+	return usage, nil
+}
+
+type expiryJWT struct {
+	ExpiresAt int64 `json:"exp"`
+}
+
+func isExpired(token string) bool {
+	var encoded string
+
+	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
+		return true
+	} else {
+		encoded = tokenSplit[1]
+	}
+
+	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
+
+	if err != nil {
+		return true
+	}
+
+	expiryData := &expiryJWT{}
+
+	err = json.Unmarshal(decodedBytes, expiryData)
+
+	if err != nil {
+		return true
+	}
+
+	expiryTime := time.Unix(expiryData.ExpiresAt, 0)
+
+	return expiryTime.Before(time.Now())
+}

+ 54 - 0
ee/billing/types.go

@@ -0,0 +1,54 @@
+// +build ee
+
+package billing
+
+type Team struct {
+	ID           string       `json:"id"`
+	ProviderID   string       `json:"provider_id"`
+	Name         string       `json:"name"`
+	Members      []Teammate   `json:"members"`
+	Subscription Subscription `json:"subscription"`
+}
+
+type RoleEnum string
+
+const (
+	RoleEnumOwner  RoleEnum = "owner"
+	RoleEnumMember RoleEnum = "member"
+)
+
+type Teammate struct {
+	ID         string   `json:"id"`
+	CustomerID string   `json:"customer_id"`
+	Role       RoleEnum `json:"role"`
+	Email      string   `json:"email"`
+}
+
+type Subscription struct {
+	ID       string `json:"id"`
+	Plan     Plan   `json:"plan"`
+	IsActive bool   `json:"is_active"`
+}
+
+type Plan struct {
+	ID         string        `json:"id"`
+	ProviderID string        `json:"string"`
+	Name       string        `json:"name"`
+	IsActive   bool          `json:"is_active"`
+	Features   []PlanFeature `json:"features"`
+}
+
+type PlanFeature struct {
+	ID          string      `json:"id"`
+	IsActive    bool        `json:"is_active"`
+	FeatureSpec FeatureSpec `json:"spec"`
+	Slug        string      `json:"slug"`
+	MaxLimit    int64       `json:"max_limit"`
+}
+
+type FeatureSpec struct {
+	ID         string `json:"id"`
+	Name       string `json:"name"`
+	MaxLimit   int64  `json:"max_limit"`
+	ProviderID string `json:"provider_id"`
+}

+ 13 - 0
ee/models/project_billing.go

@@ -0,0 +1,13 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// ProjectBilling stores a billing data per project
+type ProjectBilling struct {
+	*gorm.Model
+
+	ProjectID     uint
+	BillingTeamID string
+}

+ 15 - 0
ee/models/user_billing.go

@@ -0,0 +1,15 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// UserBilling stores a billing token per user in a project
+type UserBilling struct {
+	*gorm.Model
+
+	ProjectID  uint
+	UserID     uint
+	TeammateID string
+	Token      []byte
+}

+ 46 - 0
ee/repository/gorm/project_billing.go

@@ -0,0 +1,46 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectBillingRepository uses gorm.DB for querying the database
+type ProjectBillingRepository struct {
+	db *gorm.DB
+}
+
+func NewProjectBillingRepository(db *gorm.DB) repository.ProjectBillingRepository {
+	return &ProjectBillingRepository{db}
+}
+
+func (repo *ProjectBillingRepository) CreateProjectBilling(projBilling *models.ProjectBilling) (*models.ProjectBilling, error) {
+	if err := repo.db.Create(projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByProjectID(projID uint) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("team_id = ?", teamID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}

+ 29 - 0
ee/repository/gorm/repository.go

@@ -0,0 +1,29 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+type GormRepository struct {
+	userBilling repository.UserBillingRepository
+	projBilling repository.ProjectBillingRepository
+}
+
+func (t *GormRepository) UserBilling() repository.UserBillingRepository {
+	return t.userBilling
+}
+
+func (t *GormRepository) ProjectBilling() repository.ProjectBillingRepository {
+	return t.projBilling
+}
+
+// NewEERepository returns an EERepository
+func NewEERepository(db *gorm.DB, key *[32]byte) repository.EERepository {
+	return &GormRepository{
+		userBilling: NewUserBillingRepository(db, key),
+		projBilling: NewProjectBillingRepository(db),
+	}
+}

+ 116 - 0
ee/repository/gorm/user_billing.go

@@ -0,0 +1,116 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	cerepository "github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// UserBillingRepository uses gorm.DB for querying the database
+type UserBillingRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+func NewUserBillingRepository(db *gorm.DB, key *[32]byte) repository.UserBillingRepository {
+	return &UserBillingRepository{db, key}
+}
+
+// CreateUserBilling adds a new User row to the Users table in the database
+func (repo *UserBillingRepository) CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+func (repo *UserBillingRepository) ReadUserBilling(projectID, userID uint) (*models.UserBilling, error) {
+	userBilling := &models.UserBilling{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projectID, userID).First(&userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// UpdateUserBilling updates user billing in the db
+func (repo *UserBillingRepository) UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// EncryptUserBillingData will encrypt the user's billing data before writing
+// to the DB
+func (repo *UserBillingRepository) EncryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		cipherData, err := cerepository.Encrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = cipherData
+	}
+
+	return nil
+}
+
+// DecryptUserBillingData will decrypt the user's billing data before returning it
+// from the DB
+func (repo *UserBillingRepository) DecryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		plaintext, err := cerepository.Decrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = plaintext
+	}
+
+	return nil
+}

+ 11 - 0
ee/repository/project_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type ProjectBillingRepository interface {
+	CreateProjectBilling(userBilling *models.ProjectBilling) (*models.ProjectBilling, error)
+	ReadProjectBillingByProjectID(projectID uint) (*models.ProjectBilling, error)
+	ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error)
+}

+ 8 - 0
ee/repository/repository.go

@@ -0,0 +1,8 @@
+// +build ee
+
+package repository
+
+type EERepository interface {
+	UserBilling() UserBillingRepository
+	ProjectBilling() ProjectBillingRepository
+}

+ 11 - 0
ee/repository/user_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type UserBillingRepository interface {
+	CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+	ReadUserBilling(projectID, userID uint) (*models.UserBilling, error)
+	UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+}

+ 75 - 0
internal/billing/billing.go

@@ -0,0 +1,75 @@
+package billing
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// BillingManager contains methods for managing billing for a project
+type BillingManager interface {
+	// CreateTeam creates the concept of a billing "team". This is currently a one-to-one
+	// mapping with projects, but this may change in the future (i.e. multiple projects
+	// per same team)
+	CreateTeam(proj *models.Project) (teamID string, err error)
+
+	// GetTeamID gets the billing team id for a project
+	GetTeamID(proj *models.Project) (teamID string, err error)
+
+	// AddUserToTeam adds a user to a team, and cases on whether the user can view
+	// billing based on the role.
+	AddUserToTeam(teamID string, user *models.User, role *models.Role) error
+
+	// UpdateUserInTeam updates a user's role in a team, and cases on whether the user can view
+	// billing based on the role.
+	UpdateUserInTeam(role *models.Role) error
+
+	// RemoveUserFromTeam removes a user from a team
+	RemoveUserFromTeam(role *models.Role) error
+
+	// GetIDToken retrieves a billing token for a user. The billing token can be exchanged
+	// to view billing information.
+	GetIDToken(projectID uint, user *models.User) (token string, err error)
+
+	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
+	// from a billing agent
+	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error)
+
+	// VerifySignature verifies the signature for a webhook
+	VerifySignature(signature string, body []byte) bool
+}
+
+// NoopBillingManager performs no billing operations
+type NoopBillingManager struct{}
+
+func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, err error) {
+	return fmt.Sprintf("%d", proj.ID), nil
+}
+
+func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err error) {
+	return fmt.Sprintf("%d", proj.ID), nil
+}
+
+func (n *NoopBillingManager) AddUserToTeam(teamID string, user *models.User, role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) UpdateUserInTeam(role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) RemoveUserFromTeam(role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) GetIDToken(projectID uint, user *models.User) (token string, err error) {
+	return "", nil
+}
+
+func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error) {
+	return nil, nil
+}
+
+func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {
+	return false
+}