فهرست منبع

Merge branch 'master' into nafees/workers

Mohammed Nafees 3 سال پیش
والد
کامیت
f3ffd6c295
32فایلهای تغییر یافته به همراه589 افزوده شده و 1267 حذف شده
  1. 1 25
      api/server/handlers/billing/billing_ce.go
  2. 1 13
      api/server/handlers/billing/billing_ee.go
  3. 65 0
      api/server/handlers/billing/redirect_billing.go
  4. 15 15
      api/server/handlers/environment/finalize_deployment.go
  5. 24 0
      api/server/handlers/namespace/clone_env_group.go
  6. 2 12
      api/server/handlers/project/create.go
  7. 2 1
      api/server/handlers/project/delete.go
  8. 0 8
      api/server/handlers/project/delete_role.go
  9. 1 1
      api/server/handlers/project/get_billing.go
  10. 0 8
      api/server/handlers/project/update_role.go
  11. 24 2
      api/server/handlers/webhook/github_incoming.go
  12. 0 25
      api/server/router/base.go
  13. 12 14
      api/server/router/project.go
  14. 4 3
      api/server/shared/config/env/envconfs.go
  15. 6 7
      api/server/shared/config/loader/init_ee.go
  16. 7 0
      api/types/project.go
  17. 2 1
      cli/cmd/docker/agent.go
  18. 19 13
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  19. 10 3
      dashboard/src/main/home/sidebar/SidebarLink.tsx
  20. 0 163
      ee/api/server/handlers/billing/add_project.go
  21. 0 66
      ee/api/server/handlers/billing/get_token.go
  22. 33 1
      ee/api/server/handlers/billing/webhook.go
  23. 1 19
      ee/api/server/handlers/invite/accept.go
  24. 293 0
      ee/billing/client.go
  25. 0 677
      ee/billing/ironplans.go
  26. 30 125
      ee/billing/types.go
  27. 10 62
      internal/billing/billing.go
  28. 10 2
      internal/models/infra.go
  29. 3 1
      internal/registry/registry.go
  30. 9 0
      internal/repository/gorm/project.go
  31. 1 0
      internal/repository/project.go
  32. 4 0
      internal/repository/test/project.go

+ 1 - 25
api/server/handlers/billing/billing_ce.go

@@ -1,3 +1,4 @@
+//go:build !ee
 // +build !ee
 
 package billing
@@ -10,19 +11,6 @@ import (
 	"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
@@ -34,15 +22,3 @@ func NewBillingWebhookHandler(
 ) http.Handler {
 	return handlers.NewUnavailable(config, "billing_webhook")
 }
-
-type BillingAddProjectHandler struct {
-	handlers.PorterHandlerReader
-	handlers.Unavailable
-}
-
-func NewBillingAddProjectHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return handlers.NewUnavailable(config, "billing_add_project")
-}

+ 1 - 13
api/server/handlers/billing/billing_ee.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package billing
@@ -11,24 +12,11 @@ import (
 	"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
 
-var NewBillingAddProjectHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler
-
 func init() {
-	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
 	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
-	NewBillingAddProjectHandler = billing.NewBillingAddProjectHandler
 }

+ 65 - 0
api/server/handlers/billing/redirect_billing.go

@@ -0,0 +1,65 @@
+package billing
+
+import (
+	"net/http"
+	"net/url"
+
+	"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 RedirectBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewRedirectBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *RedirectBillingHandler {
+	return &RedirectBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *RedirectBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	if len(proj.Roles) == 0 {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Only the creator of the project can manage billing"), 302)
+		return
+	}
+
+	// at the moment, the user must be the first admin user on the project - otherwise, redirect back to
+	// home page with error
+	var firstAdminRoleID uint = proj.Roles[0].ID
+	var currUserRoleID uint = 0
+
+	for _, role := range proj.Roles {
+		if role.UserID == user.ID && role.Kind == types.RoleAdmin {
+			currUserRoleID = role.ID
+		}
+
+		if role.Kind == types.RoleAdmin && role.ID <= firstAdminRoleID {
+			firstAdminRoleID = role.ID
+		}
+	}
+
+	if currUserRoleID == 0 || currUserRoleID != firstAdminRoleID {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Only the creator of the project can manage billing"), 302)
+		return
+	}
+
+	redirectURI, err := c.Config().BillingManager.GetRedirectURI(user, proj)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	http.Redirect(w, r, redirectURI, 302)
+}

+ 15 - 15
api/server/handlers/environment/finalize_deployment.go

@@ -150,21 +150,21 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID, project.ID, url.QueryEscape(cluster.Name),
 	)
 
-	if len(request.SuccessfulResources) > 0 {
-		commentBody += "\n#### Successfully deployed resources\n"
-
-		for _, res := range request.SuccessfulResources {
-			if res.ReleaseType == "job" {
-				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
-					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
-					res.ReleaseName, project.ID)
-			} else {
-				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
-					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
-					res.ReleaseName, project.ID)
-			}
-		}
-	}
+	// if len(request.SuccessfulResources) > 0 {
+	// 	commentBody += "\n#### Successfully deployed resources\n"
+
+	// 	for _, res := range request.SuccessfulResources {
+	// 		if res.ReleaseType == "job" {
+	// 			commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+	// 				res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+	// 				res.ReleaseName, project.ID)
+	// 		} else {
+	// 			commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+	// 				res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+	// 				res.ReleaseName, project.ID)
+	// 		}
+	// 	}
+	// }
 
 	err = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
 

+ 24 - 0
api/server/handlers/namespace/clone_env_group.go

@@ -1,6 +1,8 @@
 package namespace
 
 import (
+	"errors"
+	"fmt"
 	"net/http"
 	"strings"
 
@@ -10,6 +12,7 @@ import (
 	"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/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -50,12 +53,33 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
 
 	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", request.Name, namespace), http.StatusNotFound,
+				"no config map found for envgroup",
+			))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	secret, _, err := agent.GetLatestVersionedSecret(request.Name, namespace)
 
+	if err != nil {
+		if errors.Is(err, kubernetes.IsNotFoundError) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error cloning env group: envgroup %s in namespace %s not found", request.Name, namespace), http.StatusNotFound,
+				"no k8s secret found for envgroup",
+			))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	if request.CloneName == "" {
 		request.CloneName = request.Name
 	}

+ 2 - 12
api/server/handlers/project/create.go

@@ -44,7 +44,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	var err error
-	proj, role, err := CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -79,7 +79,7 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	p.WriteResult(w, r, proj.ToProjectType())
 
 	// add project to billing team
-	teamID, err := p.Config().BillingManager.CreateTeam(proj)
+	_, err = p.Config().BillingManager.CreateTeam(user, proj)
 
 	if err != nil {
 		// we do not write error response, since setting up billing error can be
@@ -87,16 +87,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		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),
 	}))

+ 2 - 1
api/server/handlers/project/delete.go

@@ -25,6 +25,7 @@ func NewProjectDeleteHandler(
 }
 
 func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
 	proj, err := p.Repo().Project().DeleteProject(proj)
@@ -37,7 +38,7 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	p.WriteResult(w, r, proj.ToProjectType())
 
 	// delete the billing team
-	if err := p.Config().BillingManager.DeleteTeam(proj); err != nil {
+	if err := p.Config().BillingManager.DeleteTeam(user, proj); 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 - 8
api/server/handlers/project/delete_role.go

@@ -54,12 +54,4 @@ 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_billing.go

@@ -30,7 +30,7 @@ func (p *ProjectGetBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		HasBilling: false,
 	}
 
-	if sc := p.Config().ServerConf; sc.IronPlansAPIKey != "" && sc.IronPlansServerURL != "" {
+	if sc := p.Config().ServerConf; sc.BillingPrivateKey != "" && sc.BillingPrivateServerURL != "" {
 		// determine if the project has usage attached; if so, set has_billing to true
 		usage, _ := p.Repo().ProjectUsage().ReadProjectUsage(proj.ID)
 

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

@@ -57,12 +57,4 @@ 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))
-	}
 }

+ 24 - 2
api/server/handlers/webhook/github_incoming.go

@@ -158,7 +158,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
 				"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
-	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
+	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" || event.GetAction() == "edited" {
 		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
 			env.ID, owner, repo, uint(event.GetPullRequest().GetNumber()),
 		)
@@ -191,7 +191,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, depl.ID,
 					event.GetPullRequest().GetNumber(), err)
 			}
-		} else {
+		} else if event.GetAction() == "closed" {
 			// check for already running workflows we should be cancelling
 			var wg sync.WaitGroup
 			statuses := []string{"in_progress", "queued", "requested", "waiting"}
@@ -245,6 +245,28 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					"deployment deleted but errors found while trying to cancel active workflow runs %w", webhookID, owner, repo, env.ID, depl.ID,
 					event.GetPullRequest().GetNumber(), chanErr)
 			}
+		} else if event.GetChanges() != nil {
+			shouldUpdate := false
+
+			if event.GetChanges().GetTitle() != nil && event.GetPullRequest().GetTitle() != depl.PRName {
+				depl.PRName = event.GetPullRequest().GetTitle()
+				shouldUpdate = true
+			}
+
+			if event.GetChanges().GetBase() != nil && event.GetChanges().GetBase().GetRef() != nil && event.GetPullRequest().GetBase().GetRef() != depl.PRBranchInto {
+				depl.PRBranchInto = event.GetPullRequest().GetBase().GetRef()
+				shouldUpdate = true
+			}
+
+			if shouldUpdate {
+				_, err := c.Repo().Environment().UpdateDeployment(depl)
+
+				if err != nil {
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d, prNumber: %d] "+
+						"error updating deployment to reflect changes in the pull request %w", webhookID, owner, repo, env.ID, depl.ID,
+						event.GetPullRequest().GetNumber(), err)
+				}
+			}
 		}
 	}
 

+ 0 - 25
api/server/router/base.go

@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/healthcheck"
@@ -516,30 +515,6 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
-	// POST /api/internal/billing -> billing.NewBillingAddProjectHandler
-	addProjectBillingEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: "/internal/billing",
-			},
-			Scopes: []types.PermissionScope{},
-		},
-	)
-
-	addProjectBillingHandler := billing.NewBillingAddProjectHandler(
-		config,
-		factory.GetDecoderValidator(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: addProjectBillingEndpoint,
-		Handler:  addProjectBillingHandler,
-		Router:   r,
-	})
-
 	if config.ServerConf.GithubIncomingWebhookSecret != "" {
 		// POST /api/github/incoming_webhook/{webhook_id} -> webhook.NewGithubIncomingWebhook
 		githubIncomingWebhookEndpoint := factory.NewAPIEndpoint(

+ 12 - 14
api/server/router/project.go

@@ -227,14 +227,14 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
-	getBillingEndpoint := factory.NewAPIEndpoint(
+	// GET /api/project/{project_id}/billing/redirect -> billing.NewRedirectBillingHandler
+	redirectBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing",
+				RelativePath: relPath + "/billing/redirect",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -243,43 +243,41 @@ func getProjectRoutes(
 		},
 	)
 
-	getBillingHandler := project.NewProjectGetBillingHandler(
+	redirectBillingHandler := billing.NewRedirectBillingHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingEndpoint,
-		Handler:  getBillingHandler,
+		Endpoint: redirectBillingEndpoint,
+		Handler:  redirectBillingHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
-	getBillingTokenEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
+	getBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing/token",
+				RelativePath: relPath + "/billing",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
-				types.SettingsScope,
 			},
 		},
 	)
 
-	getBillingTokenHandler := billing.NewBillingGetTokenHandler(
+	getBillingHandler := project.NewProjectGetBillingHandler(
 		config,
-		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingTokenEndpoint,
-		Handler:  getBillingTokenHandler,
+		Endpoint: getBillingEndpoint,
+		Handler:  getBillingHandler,
 		Router:   r,
 	})
 

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

@@ -62,9 +62,10 @@ 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"`
-	WhitelistedUsers   []uint `env:"WHITELISTED_USERS"`
+	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
+	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`
+	BillingPublicServerURL  string `env:"BILLING_PUBLIC_URL"`
+	WhitelistedUsers        []uint `env:"WHITELISTED_USERS"`
 
 	DOClientID     string `env:"DO_CLIENT_ID"`
 	DOClientSecret string `env:"DO_CLIENT_SECRET"`

+ 6 - 7
api/server/shared/config/loader/init_ee.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package loader
@@ -6,7 +7,6 @@ import (
 	eeBilling "github.com/porter-dev/porter/ee/billing"
 	"github.com/porter-dev/porter/ee/integrations/vault"
 	"github.com/porter-dev/porter/ee/models"
-	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
 	"github.com/porter-dev/porter/internal/billing"
 )
 
@@ -24,14 +24,13 @@ func init() {
 		key[i] = b
 	}
 
-	eeRepo := eeGorm.NewEERepository(InstanceDB, &key)
-
-	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
-		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
-		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+	if InstanceEnvConf.ServerConf.BillingPrivateServerURL != "" && InstanceEnvConf.ServerConf.BillingPrivateKey != "" && InstanceEnvConf.ServerConf.BillingPublicServerURL != "" {
+		serverURL := InstanceEnvConf.ServerConf.BillingPrivateServerURL
+		publicServerURL := InstanceEnvConf.ServerConf.BillingPublicServerURL
+		apiKey := InstanceEnvConf.ServerConf.BillingPrivateKey
 		var err error
 
-		InstanceBillingManager, err = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+		InstanceBillingManager, err = eeBilling.NewClient(serverURL, publicServerURL, apiKey)
 
 		if err != nil {
 			panic(err)

+ 7 - 0
api/types/project.go

@@ -11,6 +11,13 @@ type Project struct {
 	StacksEnabled       bool    `json:"stacks_enabled"`
 }
 
+type FeatureFlags struct {
+	PreviewEnvironmentsEnabled string `json:"preview_environments_enabled,omitempty"`
+	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
+	StacksEnabled              string `json:"stacks_enabled,omitempty"`
+	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
+}
+
 type CreateProjectRequest struct {
 	Name string `json:"name" form:"required"`
 }

+ 2 - 1
cli/cmd/docker/agent.go

@@ -291,7 +291,8 @@ func (a *Agent) PullImage(image string) error {
 	out, err := a.ImagePull(a.ctx, image, opts)
 
 	if err != nil {
-		if client.IsErrNotFound(err) {
+		if client.IsErrNotFound(err) ||
+			(strings.Contains(image, "gcr.io") && strings.Contains(err.Error(), "or it may not exist")) {
 			return PullImageErrNotFound
 		} else if client.IsErrUnauthorized(err) {
 			return PullImageErrUnauthorized

+ 19 - 13
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -71,12 +71,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
     });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
-      if (this.context?.hasBillingEnabled) {
-        tabOptions.push({
-          value: "billing",
-          label: "Billing",
-        });
-      }
+      // if (this.context?.hasBillingEnabled) {
+      //   tabOptions.push({
+      //     value: "billing",
+      //     label: "Billing",
+      //   });
+      // }
 
       if (currentProject?.api_tokens_enabled) {
         tabOptions.push({
@@ -104,12 +104,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
-    if (
-      this.state.currentTab === "billing" &&
-      this.context?.hasBillingEnabled
-    ) {
-      return <BillingPage />;
-    }
+    // if (
+    //   this.state.currentTab === "billing" &&
+    //   this.context?.hasBillingEnabled
+    // ) {
+    //   return <BillingPage />;
+    // }
 
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
@@ -119,7 +119,13 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return (
         <Placeholder>
           <Helper>
-          Please contact <a href="mailto:support@porter.run">support@porter.run</a> to upgrade your project's usage limits.
+            Visit the{" "}
+            <a
+              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+            >
+              billing portal
+            </a>{" "}
+            to view plans.
           </Helper>
         </Placeholder>
       );

+ 10 - 3
dashboard/src/main/home/sidebar/SidebarLink.tsx

@@ -18,20 +18,27 @@ const SidebarLink: React.FC<{ path: string } & Omit<NavLinkProps, "to">> = ({
    */
   const withQueryParams = (path: string) => (location: any) => {
     let pathNamespace = params.namespace;
-    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
+    const search = new URLSearchParams();
+    if (currentCluster?.name) {
+      search.append("cluster", currentCluster.name);
+    }
+
+    if (currentProject?.id) {
+      search.append("project_id", String(currentProject.id));
+    }
 
     if (!pathNamespace) {
       pathNamespace = getQueryParam("namespace");
     }
 
     if (pathNamespace) {
-      search = search.concat(`&namespace=${pathNamespace}`);
+      search.append("namespace", pathNamespace);
     }
 
     return {
       ...location,
       pathname: path,
-      search,
+      search: search.toString(),
     };
   };
 

+ 0 - 163
ee/api/server/handlers/billing/add_project.go

@@ -1,163 +0,0 @@
-package billing
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"strings"
-
-	"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"
-	"gorm.io/gorm"
-)
-
-type BillingAddProjectHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-func NewBillingAddProjectHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return &BillingAddProjectHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
-	}
-}
-
-// Adds a project to a billing team in IronPlans. Takes the following steps:
-// 1. Looks for project billing data for the given project.
-// 2. Checks for project billing data. If the project already has billing data, move to step 3b, otherwise 3a.
-// 3a. Creates a new team in IronPlans, and creates a custom plan in IronPlans. Subscribes the team to the plan.
-// 3b. Finds the relevant team in IronPlans, creates a custom plan, and updates the subscription for the team.
-// 4. If team was created, creates ProjectBilling object.
-// 5. If team was created, finds all roles in the team. Adds all roles as a team member to the project billing. Updates UserBilling models.
-func (c *BillingAddProjectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// validation for internal token
-	// if internal token is empty, throw forbidden error; this server is misconfigured
-	if c.Config().ServerConf.RetoolToken == "" {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("internal retool token does not exist: re-configure the server")))
-		return
-	}
-
-	reqToken := r.Header.Get("Authorization")
-	splitToken := strings.Split(reqToken, "Bearer")
-
-	if len(splitToken) != 2 {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("no token found")))
-		return
-	}
-
-	reqToken = strings.TrimSpace(splitToken[1])
-
-	if reqToken != c.Config().ServerConf.RetoolToken {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("passed retool token does not match env")))
-		return
-	}
-
-	request := &types.AddProjectBillingRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// make sure the project exists; if it does not exist, throw forbidden error
-	proj, err := c.Repo().Project().ReadProject(request.ProjectID)
-
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-			return
-		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// look for project billing data for the given project
-	teamID, err := c.Config().BillingManager.GetTeamID(proj)
-	isNotFound := err != nil && errors.Is(err, gorm.ErrRecordNotFound)
-
-	// if the error is not nil and is not "ErrRecordNotFound", throw error
-	if err != nil && !isNotFound {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// if the team is not found, create a new team
-	if isNotFound {
-		teamID, err = c.Config().BillingManager.CreateTeam(proj)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	// determine whether to place the team on a custom plan or an existing plan
-	if request.ExistingPlanName != "" {
-		err = addToExistingPlan(c.Config(), request.ExistingPlanName, teamID)
-	} else {
-		err = addToCustomPlan(c.Config(), teamID, proj, request)
-	}
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// add users in project to the plan
-	projRoles, err := c.Repo().Project().ListProjectRoles(proj.ID)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	for _, role := range projRoles {
-		user, err := c.Repo().User().ReadUser(role.UserID)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		err = c.Config().BillingManager.AddUserToTeam(teamID, user, &role)
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
-func addToCustomPlan(c *config.Config, teamID string, proj *models.Project, req *types.AddProjectBillingRequest) error {
-	// create a new plan in IronPlans
-	planID, err := c.BillingManager.CreatePlan(teamID, proj, req)
-
-	if err != nil {
-		return err
-	}
-
-	// create a new subscription to this plan in IronPlans
-	return c.BillingManager.CreateOrUpdateSubscription(teamID, planID)
-}
-
-func addToExistingPlan(c *config.Config, existingPlanName, teamID string) error {
-	// look for existing plans in IronPlans
-	planID, err := c.BillingManager.GetExistingPublicPlan(existingPlanName)
-
-	if err != nil {
-		return err
-	}
-
-	// create a new subscription to this plan in IronPlans
-	return c.BillingManager.CreateOrUpdateSubscription(teamID, planID)
-}

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

@@ -1,66 +0,0 @@
-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, teamID, err := c.Config().BillingManager.GetIDToken(proj, user)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	c.WriteResult(w, r, &types.GetBillingTokenResponse{
-		Token:  token,
-		TeamID: teamID,
-	})
-}

+ 33 - 1
ee/api/server/handlers/billing/webhook.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"strconv"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -48,7 +49,7 @@ func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	// parse usage and update project
-	newUsage, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
+	newUsage, features, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -81,4 +82,35 @@ func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	// update the feature flags
+	project, err := c.Repo().Project().ReadProject(newUsage.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if managedDatabasesEnabled, err := strconv.ParseBool(features.ManagedDatabasesEnabled); err == nil {
+		project.RDSDatabasesEnabled = managedDatabasesEnabled
+	}
+
+	if managedInfraEnabled, err := strconv.ParseBool(features.ManagedInfraEnabled); err == nil {
+		project.ManagedInfraEnabled = managedInfraEnabled
+	}
+
+	if stacksEnabled, err := strconv.ParseBool(features.StacksEnabled); err == nil {
+		project.StacksEnabled = stacksEnabled
+	}
+
+	if previewEnvsEnabled, err := strconv.ParseBool(features.PreviewEnvironmentsEnabled); err == nil {
+		project.PreviewEnvsEnabled = previewEnvsEnabled
+	}
+
+	_, err = c.Repo().Project().UpdateProject(project)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }

+ 1 - 19
ee/api/server/handlers/invite/accept.go

@@ -1,3 +1,4 @@
+//go:build ee
 // +build ee
 
 package invite
@@ -104,24 +105,5 @@ 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)
 }

+ 293 - 0
ee/billing/client.go

@@ -0,0 +1,293 @@
+//go:build ee
+// +build ee
+
+package billing
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/api/types"
+	cemodels "github.com/porter-dev/porter/internal/models"
+)
+
+// Client contains an API client for the internal billing engine
+type Client struct {
+	apiKey          string
+	serverURL       string
+	publicServerURL string
+	httpClient      *http.Client
+}
+
+// NewClient creates a new billing API client
+func NewClient(serverURL, publicServerURL, apiKey string) (*Client, error) {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	client := &Client{apiKey, serverURL, publicServerURL, httpClient}
+
+	return client, nil
+}
+
+func (c *Client) CreateTeam(user *cemodels.User, proj *cemodels.Project) (string, error) {
+	// call the internal billing endpoint to create a new customer in the database
+	reqData := &CreateCustomerRequest{
+		Email:       user.Email,
+		UserID:      user.ID,
+		ProjectID:   proj.ID,
+		ProjectName: proj.Name,
+	}
+
+	err := c.postRequest("/api/v1/private/customer", reqData, nil)
+
+	if err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf("%d-%d", proj.ID, user.ID), nil
+}
+
+func (c *Client) DeleteTeam(user *cemodels.User, proj *cemodels.Project) error {
+	// call delete customer
+	reqData := &DeleteCustomerRequest{
+		UserID:    user.ID,
+		ProjectID: proj.ID,
+	}
+
+	return c.deleteRequest("/api/v1/private/customer", reqData, nil)
+}
+
+func (c *Client) GetRedirectURI(user *cemodels.User, proj *cemodels.Project) (string, error) {
+	// get an internal cookie
+	reqData := &CreateBillingCookieRequest{
+		ProjectName: proj.Name,
+		ProjectID:   proj.ID,
+		UserID:      user.ID,
+		Email:       user.Email,
+	}
+
+	createCookieVals := make(map[string][]string)
+	err := schema.NewEncoder().Encode(reqData, createCookieVals)
+
+	if err != nil {
+		return "", err
+	}
+
+	urlVals := url.Values(createCookieVals)
+	encodedURLVals := urlVals.Encode()
+
+	dst := &CreateBillingCookieResponse{}
+
+	err = c.postRequest("/api/v1/private/cookie", reqData, dst)
+
+	if err != nil {
+		return "", err
+	}
+
+	redirectData := &VerifyUserRequest{
+		TokenID: dst.TokenID,
+		Token:   dst.Token,
+	}
+
+	vals := make(map[string][]string)
+	err = schema.NewEncoder().Encode(redirectData, vals)
+
+	if err != nil {
+		return "", err
+	}
+
+	urlVals = url.Values(vals)
+	encodedURLVals = urlVals.Encode()
+
+	return fmt.Sprintf("%s/api/v1/verify?%s", c.publicServerURL, encodedURLVals), nil
+}
+
+// VerifySignature verifies a webhook signature based on hmac protocol
+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) getRequest(path string, dst interface{}, query ...map[string]string) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	q := reqURL.Query()
+	for _, queryGroup := range query {
+		for key, val := range queryGroup {
+			q.Add(key, val)
+		}
+	}
+
+	reqURL.RawQuery = q.Encode()
+
+	req, err := http.NewRequest(
+		"GET",
+		reqURL.String(),
+		nil,
+	)
+
+	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
+}
+
+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
+}
+
+const (
+	FeatureSlugCPU      string = "cpu"
+	FeatureSlugMemory   string = "memory"
+	FeatureSlugClusters string = "clusters"
+	FeatureSlugUsers    string = "users"
+)
+
+func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.ProjectUsage, *types.FeatureFlags, error) {
+	usageData := &APIWebhookRequest{}
+
+	err := json.Unmarshal(payload, usageData)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return &cemodels.ProjectUsage{
+			ProjectID:      usageData.ProjectID,
+			ResourceCPU:    usageData.CPU,
+			ResourceMemory: usageData.Memory * 1000,
+			Clusters:       usageData.Clusters,
+			Users:          usageData.Users,
+		}, &types.FeatureFlags{
+			PreviewEnvironmentsEnabled: usageData.PreviewEnvironmentsEnabled,
+			ManagedInfraEnabled:        usageData.ManagedInfraEnabled,
+			StacksEnabled:              usageData.StacksEnabled,
+			ManagedDatabasesEnabled:    usageData.ManagedDatabasesEnabled,
+		}, nil
+}

+ 0 - 677
ee/billing/ironplans.go

@@ -1,677 +0,0 @@
-// +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 IronPlans
-type Client struct {
-	apiKey    string
-	serverURL string
-	repo      repository.EERepository
-
-	httpClient *http.Client
-
-	defaultPlanID string
-	customPlanID  string
-}
-
-// NewClient creates a new billing API client
-func NewClient(serverURL, apiKey string, repo repository.EERepository) (*Client, error) {
-	httpClient := &http.Client{
-		Timeout: time.Minute,
-	}
-
-	client := &Client{apiKey, serverURL, repo, httpClient, "", ""}
-
-	// get the default plans from the IronPlans API server
-	defPlanID, err := client.GetExistingPublicPlan("Free")
-
-	if err != nil {
-		return nil, err
-	}
-
-	customPlanID, err := client.GetExistingPublicPlan("Enterprise")
-
-	if err != nil {
-		return nil, err
-	}
-
-	client.defaultPlanID = defPlanID
-	client.customPlanID = customPlanID
-
-	return client, nil
-}
-
-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
-	}
-
-	// put the user on the free plan, as the default behavior, if there is a default plan
-	if c.defaultPlanID != "" {
-		err = c.CreateOrUpdateSubscription(resp.ID, c.defaultPlanID)
-
-		if err != nil {
-			return "", fmt.Errorf("subscription creation failed: %s", 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) DeleteTeam(proj *cemodels.Project) error {
-	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
-
-	if err != nil {
-		return err
-	}
-
-	return c.deleteRequest(fmt.Sprintf("/teams/v1/%s", projBilling.BillingTeamID), nil, nil)
-}
-
-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) CreatePlan(teamID string, proj *cemodels.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
-	// construct basic plan object
-	planFeatures := make([]*CreatePlanFeature, 0)
-
-	userDisplay := fmt.Sprintf("Up to %d users", planSpec.Users)
-
-	if planSpec.Users == 0 {
-		userDisplay = fmt.Sprintf("Unlimited users")
-	}
-
-	clusterDisplay := fmt.Sprintf("Up to %d clusters", planSpec.Clusters)
-
-	if planSpec.Clusters == 0 {
-		clusterDisplay = fmt.Sprintf("Unlimited clusters")
-	}
-
-	cpuDisplay := fmt.Sprintf("Up to %d CPUs", planSpec.CPU)
-
-	if planSpec.CPU == 0 {
-		cpuDisplay = fmt.Sprintf("Unlimited CPU")
-	}
-
-	ramDisplay := fmt.Sprintf("Up to %d GB RAM", planSpec.Memory)
-
-	if planSpec.Memory == 0 {
-		ramDisplay = fmt.Sprintf("Unlimited RAM")
-	}
-
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: userDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: clusterDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: cpuDisplay,
-	})
-	planFeatures = append(planFeatures, &CreatePlanFeature{
-		Display: ramDisplay,
-	})
-
-	var customPlanID *string
-
-	if c.customPlanID != "" {
-		customPlanID = &c.customPlanID
-	}
-
-	createPlanReq := &CreatePlanRequest{
-		Name:               proj.Name,
-		IsActive:           true,
-		IsPublic:           false,
-		IsTrialAllowed:     true,
-		ReplacePlanID:      customPlanID,
-		PerMonthPriceCents: planSpec.Price,
-		PerYearPriceCents:  12 * planSpec.Price,
-		Features:           planFeatures,
-		TeamsAccess: []*CreatePlanTeamsAccess{
-			{
-				TeamID: teamID,
-				Revoke: false,
-			},
-		},
-	}
-
-	// find all relevant feature IDs
-	listResp := &ListFeaturesResponse{}
-	err := c.getRequest("/features/v1", listResp)
-
-	if err != nil {
-		return "", err
-	}
-
-	// create a feature spec per feature ID, and add to features array for plan
-	for _, feature := range listResp.Results {
-		featureSpec := &CreateFeatureSpecRequest{
-			Name:         "unnamed",
-			RecordPeriod: "monthly",
-			Aggregation:  "sum",
-			UnitPrice:    0,
-		}
-
-		switch feature.Slug {
-		case FeatureSlugUsers:
-			featureSpec.MaxLimit = planSpec.Users
-			featureSpec.UnitsIncluded = planSpec.Users
-		case FeatureSlugClusters:
-			featureSpec.MaxLimit = planSpec.Clusters
-			featureSpec.UnitsIncluded = planSpec.Clusters
-		case FeatureSlugCPU:
-			featureSpec.MaxLimit = planSpec.CPU
-			featureSpec.UnitsIncluded = planSpec.CPU
-		case FeatureSlugMemory:
-			featureSpec.MaxLimit = planSpec.Memory
-			featureSpec.UnitsIncluded = planSpec.Memory
-		// continue on default behavior so that feature spec is not created for
-		// features that don't match a slug
-		default:
-			continue
-		}
-
-		// create the feature spec
-		resp := &CreateFeaturespecResponse{}
-		err = c.postRequest("/featurespecs/v1/", featureSpec, resp)
-
-		if err != nil {
-			return "", err
-		}
-
-		var index int
-		switch feature.Slug {
-		case FeatureSlugUsers:
-			index = 0
-		case FeatureSlugClusters:
-			index = 1
-		case FeatureSlugCPU:
-			index = 2
-		case FeatureSlugMemory:
-			index = 3
-		}
-
-		createPlanReq.Features[index].FeatureID = feature.ID
-		createPlanReq.Features[index].SpecID = resp.ID
-	}
-
-	// create the plan and return the plan ID
-	planResp := &Plan{}
-
-	err = c.postRequest("/plans/v1/", createPlanReq, planResp)
-
-	if err != nil {
-		return "", err
-	}
-
-	return planResp.ID, nil
-}
-
-func (c *Client) CreateOrUpdateSubscription(teamID, planID string) error {
-	// determine if subscription already exists by reading the team ID and seeing if the subscription
-	// field has an ID attached
-	teamResp := &Team{}
-	err := c.getRequest(fmt.Sprintf("/teams/v1/%s", teamID), teamResp)
-
-	if err != nil {
-		return err
-	}
-
-	subReq := &CreateSubscriptionRequest{
-		PlanID:     planID,
-		NextPlanID: c.defaultPlanID,
-		TeamID:     teamID,
-		IsPaused:   false,
-	}
-
-	// if subscription ID is not empty, perform a PUT request to update the subscription
-	if teamResp.Subscription.ID != "" {
-		// delete the subscription
-		err = c.deleteRequest(fmt.Sprintf("/subscriptions/v1/%s/purge/", teamResp.Subscription.ID), nil, nil)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	return c.postRequest("/subscriptions/v1", subReq, nil)
-}
-
-func (c *Client) GetExistingPublicPlan(planName string) (string, error) {
-	listResp := &ListPlansResponse{}
-	err := c.getRequest("/plans/v1/", listResp, map[string]string{"is_public": "true"})
-
-	if err != nil {
-		return "", err
-	}
-
-	for _, plan := range listResp.Results {
-		if plan.Name == planName {
-			return plan.ID, nil
-		}
-	}
-
-	return "", fmt.Errorf("plan not found")
-}
-
-func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
-	// determine if user is already in team/has user billing
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, user.ID)
-
-	if userBilling != nil {
-		return nil
-	}
-
-	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(proj *cemodels.Project, user *cemodels.User) (token string, teamID string, err error) {
-	// attempt to get a team ID for the project
-	teamID, err = c.GetTeamID(proj)
-
-	// attempt to read the user billing data from the project
-	userBilling, err := c.repo.UserBilling().ReadUserBilling(proj.ID, 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, teamID, nil
-			}
-		}
-	}
-
-	req := &CreateIDTokenRequest{
-		Email:  user.Email,
-		UserID: fmt.Sprintf("%d-%d", proj.ID, 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: proj.ID,
-			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:  proj.ID,
-			UserID:     user.ID,
-			Token:      []byte(token),
-			TeammateID: userBilling.TeammateID,
-		})
-
-		if err != nil {
-			return "", "", err
-		}
-	}
-
-	return token, teamID, nil
-}
-
-// VerifySignature verifies a webhook signature based on hmac protocol
-// 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) getRequest(path string, dst interface{}, query ...map[string]string) error {
-	reqURL, err := url.Parse(c.serverURL)
-
-	if err != nil {
-		return nil
-	}
-
-	reqURL.Path = path
-
-	q := reqURL.Query()
-	for _, queryGroup := range query {
-		for key, val := range queryGroup {
-			q.Add(key, val)
-		}
-	}
-
-	reqURL.RawQuery = q.Encode()
-
-	req, err := http.NewRequest(
-		"GET",
-		reqURL.String(),
-		nil,
-	)
-
-	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
-}
-
-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
-}
-
-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
-	}
-
-	// if event type is not subscription, return wrong webhook event type error
-	if subscription.EventType != "subscription" {
-		return nil, nil
-	}
-
-	// 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.FeatureSpec.MaxLimit)
-		switch feature.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())
-}

+ 30 - 125
ee/billing/types.go

@@ -1,142 +1,47 @@
+//go:build ee
 // +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 CreateCustomerRequest struct {
+	Email       string `json:"email" form:"required"`
+	UserID      uint   `json:"user_id" form:"required"`
+	ProjectID   uint   `json:"project_id" form:"required"`
+	ProjectName string `json:"project_name" form:"required"`
 }
 
-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 CreatePlanRequest struct {
-	Name               string                   `json:"name"`
-	IsActive           bool                     `json:"is_active"`
-	IsPublic           bool                     `json:"is_public"`
-	IsTrialAllowed     bool                     `json:"is_trial_allowed"`
-	PerMonthPriceCents uint                     `json:"per_month_price_cents"`
-	PerYearPriceCents  uint                     `json:"per_year_price_cents"`
-	ReplacePlanID      *string                  `json:"replace_plan_id"`
-	Features           []*CreatePlanFeature     `json:"features"`
-	TeamsAccess        []*CreatePlanTeamsAccess `json:"teams_access"`
-}
-
-type CreatePlanFeature struct {
-	FeatureID string `json:"feature_id"`
-	SpecID    string `json:"spec_id"`
-	Display   string `json:"display"`
-	Sort      uint   `json:"sort"`
-	IsActive  bool   `json:"is_active"`
-}
-
-type CreatePlanTeamsAccess struct {
-	TeamID string `json:"team_id"`
-	Revoke bool   `json:"revoke"`
-}
-
-type CreateFeatureSpecRequest struct {
-	Name          string `json:"name"`
-	RecordPeriod  string `json:"record_period"`
-	Aggregation   string `json:"aggregation"`
-	MaxLimit      uint   `json:"max_limit"`
-	UnitPrice     uint   `json:"unit_price"`
-	UnitsIncluded uint   `json:"units_included"`
-}
-
-type CreateFeaturespecResponse struct {
-	*CreateFeatureSpecRequest
-	ID string `json:"id"`
-}
-
-type ListFeaturesResponse struct {
-	Results []Feature `json:"results"`
-}
-
-type ListPlansResponse struct {
-	Results []Plan `json:"results"`
-}
-
-type PlanFeature struct {
-	ID          string      `json:"id"`
-	IsActive    bool        `json:"is_active"`
-	Feature     Feature     `json:"feature"`
-	FeatureSpec FeatureSpec `json:"spec"`
+type DeleteCustomerRequest struct {
+	UserID    uint `json:"user_id" form:"required"`
+	ProjectID uint `json:"project_id" form:"required"`
 }
 
-type Feature struct {
-	ID   string `json:"id"`
-	Slug string `json:"slug"`
-}
+type APIWebhookRequest struct {
+	ProjectID uint `json:"project_id" form:"required"`
 
-type FeatureSpec struct {
-	ID         string `json:"id"`
-	Name       string `json:"name"`
-	MaxLimit   int64  `json:"max_limit"`
-	ProviderID string `json:"provider_id"`
-}
-
-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"`
-}
+	Clusters uint `json:"clusters" form:"required"`
+	Users    uint `json:"users" form:"required"`
+	CPU      uint `json:"cpu" form:"required"`
+	Memory   uint `json:"memory" form:"required"`
 
-type CreateIDTokenRequest struct {
-	Email  string `json:"customer_email"`
-	UserID string `json:"customer_source_id"`
+	PreviewEnvironmentsEnabled string `json:"preview_environments_enabled,omitempty"`
+	ManagedInfraEnabled        string `json:"managed_infra_enabled,omitempty"`
+	StacksEnabled              string `json:"stacks_enabled,omitempty"`
+	ManagedDatabasesEnabled    string `json:"managed_databases_enabled,omitempty"`
 }
 
-type CreateIDTokenResponse struct {
-	Token string `json:"token"`
+type CreateBillingCookieRequest struct {
+	Email       string `json:"email" form:"required"`
+	UserID      uint   `json:"user_id" form:"required"`
+	ProjectID   uint   `json:"project_id" form:"required"`
+	ProjectName string `json:"project_name" form:"required"`
 }
 
-type SubscriptionWebhookRequest struct {
-	EventType string `json:"event_type"`
-	TeamID    string `json:"team_id"`
-	Plan      Plan   `json:"plan"`
+type CreateBillingCookieResponse struct {
+	Token   string `json:"token"`
+	TokenID string `json:"token_id"`
 }
 
-type CreateSubscriptionRequest struct {
-	PlanID     string `json:"plan_id"`
-	TeamID     string `json:"team_id"`
-	IsPaused   bool   `json:"is_paused"`
-	NextPlanID string `json:"next_plan_id"`
+type VerifyUserRequest struct {
+	TokenID string `schema:"token_id" form:"required"`
+	Token   string `schema:"token" form:"required"`
 }

+ 10 - 62
internal/billing/billing.go

@@ -12,41 +12,17 @@ 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)
+	CreateTeam(user *models.User, proj *models.Project) (teamID string, err error)
 
 	// DeleteTeam deletes a billing team.
-	DeleteTeam(proj *models.Project) (err error)
+	DeleteTeam(user *models.User, proj *models.Project) (err error)
 
-	// GetTeamID gets the billing team id for a project
-	GetTeamID(proj *models.Project) (teamID string, err error)
-
-	// CreatePlan creates a new plan based on the requested limits
-	CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error)
-
-	// CreateOrUpdateSubscription creates or updates a new subscription to a plan, based on a team and plan ID
-	CreateOrUpdateSubscription(teamID, planID string) error
-
-	// GetExistingPublicPlan returns an existing public plan based on a name
-	GetExistingPublicPlan(planName string) (string, 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(proj *models.Project, user *models.User) (token string, teamID string, err error)
+	// GetRedirectURI gets the redirect URI to send the user to the billing portal
+	GetRedirectURI(user *models.User, proj *models.Project) (url string, err error)
 
 	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
 	// from a billing agent
-	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error)
+	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error)
 
 	// VerifySignature verifies the signature for a webhook
 	VerifySignature(signature string, body []byte) bool
@@ -55,48 +31,20 @@ type BillingManager interface {
 // NoopBillingManager performs no billing operations
 type NoopBillingManager struct{}
 
-func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, err error) {
+func (n *NoopBillingManager) CreateTeam(user *models.User, proj *models.Project) (teamID string, err error) {
 	return fmt.Sprintf("%d", proj.ID), nil
 }
 
-func (n *NoopBillingManager) DeleteTeam(proj *models.Project) (err error) {
+func (n *NoopBillingManager) DeleteTeam(user *models.User, proj *models.Project) (err error) {
 	return nil
 }
 
-func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err error) {
-	return fmt.Sprintf("%d", proj.ID), nil
-}
-
-func (n *NoopBillingManager) CreatePlan(teamID string, proj *models.Project, planSpec *types.AddProjectBillingRequest) (string, error) {
+func (n *NoopBillingManager) GetRedirectURI(user *models.User, proj *models.Project) (url string, err error) {
 	return "", nil
 }
 
-func (n *NoopBillingManager) CreateOrUpdateSubscription(teamID, planID string) error {
-	return nil
-}
-
-func (n *NoopBillingManager) GetExistingPublicPlan(planName string) (string, error) {
-	return "", 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(proj *models.Project, user *models.User) (token string, teamID string, err error) {
-	return "", "", nil
-}
-
-func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error) {
-	return nil, nil
+func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error) {
+	return nil, nil, nil
 }
 
 func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {

+ 10 - 2
internal/models/infra.go

@@ -128,6 +128,8 @@ func GetOperationID() (string, error) {
 type getInfraName struct {
 	Name        string `json:"name"`
 	ClusterName string `json:"cluster_name"`
+	DBName      string `json:"db_name"`
+	BucketName  string `json:"bucket_name"`
 	DOCRName    string `json:"docr_name"`
 	ECRName     string `json:"ecr_name"`
 	ACRName     string `json:"acr_name"`
@@ -152,6 +154,14 @@ func (i *Infra) ToInfraType() *types.Infra {
 			name = infraName.ACRName
 		}
 
+		if infraName.DBName != "" {
+			name = infraName.DBName
+		}
+
+		if infraName.BucketName != "" {
+			name = infraName.BucketName
+		}
+
 		if infraName.ClusterName != "" {
 			name = infraName.ClusterName
 		}
@@ -159,8 +169,6 @@ func (i *Infra) ToInfraType() *types.Infra {
 		if infraName.Name != "" {
 			name = infraName.Name
 		}
-	} else if err != nil {
-		fmt.Println("ERRWAS", err)
 	}
 
 	return &types.Infra{

+ 3 - 1
internal/registry/registry.go

@@ -707,7 +707,9 @@ func (r *Registry) GetECRPaginatedImages(
 	imageIDMap := make(map[string]bool)
 
 	for _, id := range resp.ImageIds {
-		imageIDMap[*id.ImageTag] = true
+		if id != nil && id.ImageTag != nil {
+			imageIDMap[*id.ImageTag] = true
+		}
 	}
 
 	var wg sync.WaitGroup

+ 9 - 0
internal/repository/gorm/project.go

@@ -41,6 +41,15 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+// UpdateProject updates an existing project
+func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.Project, error) {
+	if err := repo.db.Save(project).Error; err != nil {
+		return nil, err
+	}
+
+	return project, nil
+}
+
 func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	foundRole := &models.Role{}
 

+ 1 - 0
internal/repository/project.go

@@ -11,6 +11,7 @@ type WriteProject func(project *models.Project) (*models.Project, error)
 type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
+	UpdateProject(project *models.Project) (*models.Project, error)
 	UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
 	ReadProjectRole(projID, userID uint) (*models.Role, error)

+ 4 - 0
internal/repository/test/project.go

@@ -60,6 +60,10 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+func (repo *ProjectRepository) UpdateProject(project *models.Project) (*models.Project, error) {
+	panic("unimplemented")
+}
+
 // CreateProjectRole appends a role to the existing array of roles
 func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
 	if !repo.canQuery {