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

Stripe integration (#4433)

Co-authored-by: jusrhee <justin@porter.run>
Mauricio Araujo пре 2 година
родитељ
комит
0cf265a081
40 измењених фајлова са 1170 додато и 775 уклоњено
  1. 0 24
      api/server/handlers/billing/billing_ce.go
  2. 0 22
      api/server/handlers/billing/billing_ee.go
  3. 46 0
      api/server/handlers/billing/create.go
  4. 67 0
      api/server/handlers/billing/customer.go
  5. 50 0
      api/server/handlers/billing/delete.go
  6. 76 0
      api/server/handlers/billing/list.go
  7. 0 9
      api/server/handlers/billing/redirect_billing.go
  8. 0 13
      api/server/handlers/project/create.go
  9. 8 7
      api/server/handlers/project/delete.go
  10. 78 19
      api/server/router/project.go
  11. 1 0
      api/server/shared/config/env/envconfs.go
  12. 16 14
      api/server/shared/config/loader/init_ee.go
  13. 7 1
      api/server/shared/config/loader/loader.go
  14. 12 11
      api/types/billing.go
  15. 3 0
      api/types/project.go
  16. 1 0
      api/types/request.go
  17. 36 0
      dashboard/package-lock.json
  18. 2 0
      dashboard/package.json
  19. 1 0
      dashboard/src/assets/credit-card.svg
  20. 15 0
      dashboard/src/lib/billing/types.tsx
  21. 146 0
      dashboard/src/lib/hooks/useStripe.tsx
  22. 1 54
      dashboard/src/main/home/Home.tsx
  23. 35 6
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  24. 98 0
      dashboard/src/main/home/modals/BillingModal.tsx
  25. 71 0
      dashboard/src/main/home/modals/PaymentSetupForm.tsx
  26. 16 17
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  27. 120 60
      dashboard/src/main/home/project-settings/BillingPage.tsx
  28. 10 25
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  29. 66 12
      dashboard/src/shared/api.tsx
  30. 7 6
      dashboard/src/shared/types.tsx
  31. 0 117
      ee/api/server/handlers/billing/webhook.go
  32. 0 284
      ee/billing/client.go
  33. 0 50
      ee/billing/types.go
  34. 1 0
      go.mod
  35. 2 0
      go.sum
  36. 33 24
      internal/billing/billing.go
  37. 127 0
      internal/billing/stripe.go
  38. 12 0
      internal/models/project.go
  39. 3 0
      zarf/helm/.dashboardenv
  40. 3 0
      zarf/helm/.serverenv

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

@@ -1,24 +0,0 @@
-//go:build !ee
-// +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 BillingWebhookHandler struct {
-	handlers.PorterHandlerReader
-	handlers.Unavailable
-}
-
-func NewBillingWebhookHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler {
-	return handlers.NewUnavailable(config, "billing_webhook")
-}

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

@@ -1,22 +0,0 @@
-//go:build ee
-// +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 NewBillingWebhookHandler func(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-) http.Handler
-
-func init() {
-	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
-}

+ 46 - 0
api/server/handlers/billing/create.go

@@ -0,0 +1,46 @@
+package billing
+
+import (
+	"fmt"
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreateBillingHandler is a handler for creating payment methods
+type CreateBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewCreateBillingHandler will create a new CreateBillingHandler
+func NewCreateBillingHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateBillingHandler {
+	return &CreateBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "create-billing-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	clientSecret, err := c.Config().BillingManager.CreatePaymentMethod(proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error creating payment method")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating payment method: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, clientSecret)
+}

+ 67 - 0
api/server/handlers/billing/customer.go

@@ -0,0 +1,67 @@
+package billing
+
+import (
+	"fmt"
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreateBillingCustomerHandler will create a new handler
+// for creating customers in the billing provider
+type CreateBillingCustomerHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCreateBillingCustomerIfNotExists will create a new CreateBillingCustomerIfNotExists
+func NewCreateBillingCustomerIfNotExists(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateBillingCustomerHandler {
+	return &CreateBillingCustomerHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateBillingCustomerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "create-billing-customer-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateBillingCustomerRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if proj.BillingID != "" {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	// Create customer in Stripe
+	customerID, err := c.Config().BillingManager.CreateCustomer(request.UserEmail, proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error creating billing customer")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating billing customer: %w", err)))
+		return
+	}
+
+	// Update the project record with the customer ID
+	proj.BillingID = customerID
+	_, err = c.Repo().Project().UpdateProject(proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error updating project")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error updating project: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 50 - 0
api/server/handlers/billing/delete.go

@@ -0,0 +1,50 @@
+package billing
+
+import (
+	"fmt"
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeleteBillingHandler is a handler for deleting payment methods
+type DeleteBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewDeleteBillingHandler will create a new DeleteBillingHandler
+func NewDeleteBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *DeleteBillingHandler {
+	return &DeleteBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *DeleteBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "delete-billing-endpoint")
+	defer span.End()
+
+	paymentMethodID, reqErr := requestutils.GetURLParamString(r, types.URLParamPaymentMethodID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error deleting payment method")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting payment method: %w", err)))
+		return
+	}
+
+	err := c.Config().BillingManager.DeletePaymentMethod(paymentMethodID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error deleting payment method")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting payment method: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 76 - 0
api/server/handlers/billing/list.go

@@ -0,0 +1,76 @@
+package billing
+
+import (
+	"fmt"
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListBillingHandler is a handler for listing payment methods
+type ListBillingHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// CheckPaymentEnabledHandler is a handler for checking if payment is setup
+type CheckPaymentEnabledHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewListBillingHandler will create a new ListBillingHandler
+func NewListBillingHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListBillingHandler {
+	return &ListBillingHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *ListBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "list-payment-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	paymentMethods, err := c.Config().BillingManager.ListPaymentMethod(proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing payment method")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing payment method: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, paymentMethods)
+}
+
+// NewCheckPaymentEnabledHandler will create a new CheckPaymentEnabledHandler
+func NewCheckPaymentEnabledHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *CheckPaymentEnabledHandler {
+	return &CheckPaymentEnabledHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *CheckPaymentEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "check-payment-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	paymentEnabled, err := c.Config().BillingManager.CheckPaymentEnabled(proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error checking if payment enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error checking if payment enabled: %w", err)))
+		return
+	}
+
+	c.WriteResult(w, r, paymentEnabled)
+}

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

@@ -6,7 +6,6 @@ import (
 
 	"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"
@@ -53,12 +52,4 @@ func (c *RedirectBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		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)
 }

+ 0 - 13
api/server/handlers/project/create.go

@@ -51,7 +51,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	var err error
 	proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
-
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -68,7 +67,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		ProjectID:   proj.ID,
 		CurrentStep: step,
 	})
-
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -82,7 +80,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Clusters:       types.BasicPlan.Clusters,
 		Users:          types.BasicPlan.Users,
 	})
-
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -90,15 +87,6 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	p.WriteResult(w, r, proj.ToProjectType(p.Config().LaunchDarklyClient))
 
-	// add project to billing team
-	_, err = p.Config().BillingManager.CreateTeam(user, 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))
-	}
-
 	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateDeleteTrackOpts{
 		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
 	}))
@@ -128,7 +116,6 @@ func CreateProjectWithUser(
 
 	// read the project again to get the model with the role attached
 	proj, err = projectRepo.ReadProject(proj.ID)
-
 	if err != nil {
 		return nil, nil, err
 	}

+ 8 - 7
api/server/handlers/project/delete.go

@@ -92,6 +92,14 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	err = p.Config().BillingManager.DeleteCustomer(proj)
+	if err != nil {
+		e := "error deleting project in billing provider"
+		err = telemetry.Error(ctx, span, err, e)
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	deletedProject, err := p.Repo().Project().DeleteProject(proj)
 	if err != nil {
 		e := "error deleting project"
@@ -117,11 +125,4 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	p.WriteResult(w, r, deletedProject.ToProjectType(p.Config().LaunchDarklyClient))
-
-	// delete the billing team
-	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))
-	}
 }

+ 78 - 19
api/server/router/project.go

@@ -285,14 +285,14 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/project/{project_id}/billing/redirect -> billing.NewRedirectBillingHandler
-	redirectBillingEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/billing -> project.NewCheckPaymentEnabledHandler
+	checkPaymentEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing/redirect",
+				RelativePath: relPath + "/billing",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -301,25 +301,80 @@ func getProjectRoutes(
 		},
 	)
 
-	redirectBillingHandler := billing.NewRedirectBillingHandler(
+	checkPaymentHandler := billing.NewCheckPaymentEnabledHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: redirectBillingEndpoint,
-		Handler:  redirectBillingHandler,
+		Endpoint: checkPaymentEndpoint,
+		Handler:  checkPaymentHandler,
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing -> project.NewProjectGetBillingHandler
-	getBillingEndpoint := factory.NewAPIEndpoint(
+	// GET /api/projects/{project_id}/billing/payment_method -> project.NewListBillingHandler
+	listBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/billing",
+				RelativePath: relPath + "/billing/payment_method",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listBillingHandler := billing.NewListBillingHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listBillingEndpoint,
+		Handler:  listBillingHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/billing/payment_method -> project.NewCreateBillingHandler
+	createBillingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/payment_method",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createBillingHandler := billing.NewCreateBillingHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createBillingEndpoint,
+		Handler:  createBillingHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/billing/payment_method/{payment_method_id} -> project.NewDeleteBillingHandler
+	deleteBillingEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/billing/payment_method/{%s}", relPath, types.URLParamPaymentMethodID),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -328,38 +383,42 @@ func getProjectRoutes(
 		},
 	)
 
-	getBillingHandler := project.NewProjectGetBillingHandler(
+	deleteBillingHandler := billing.NewDeleteBillingHandler(
 		config,
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingEndpoint,
-		Handler:  getBillingHandler,
+		Endpoint: deleteBillingEndpoint,
+		Handler:  deleteBillingHandler,
 		Router:   r,
 	})
 
-	// GET /api/billing_webhook -> billing.NewBillingWebhookHandler
-	getBillingWebhookEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/billing/customer/ -> project.NewGetOrCreateCustomerHandler
+	getOrCreateBillingCustomerEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: "/billing_webhook",
+				RelativePath: relPath + "/billing/customer",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
 			},
-			Scopes: []types.PermissionScope{},
 		},
 	)
 
-	getBillingWebhookHandler := billing.NewBillingWebhookHandler(
+	getOrCreateBillingCustomerHandler := billing.NewCreateBillingCustomerIfNotExists(
 		config,
 		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: getBillingWebhookEndpoint,
-		Handler:  getBillingWebhookHandler,
+		Endpoint: getOrCreateBillingCustomerEndpoint,
+		Handler:  getOrCreateBillingCustomerHandler,
 		Router:   r,
 	})
 

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

@@ -69,6 +69,7 @@ type ServerConf struct {
 	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
+	StripeSecretKey   string `env:"STRIPE_SECRET_KEY"`
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`
 	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 

+ 16 - 14
api/server/shared/config/loader/init_ee.go

@@ -4,9 +4,9 @@
 package loader
 
 import (
-	eeBilling "github.com/porter-dev/porter/ee/billing"
+	// TODO: delete once the billing code is cleaned up
+	// eeBilling "github.com/porter-dev/porter/ee/billing"
 	"github.com/porter-dev/porter/ee/models"
-	"github.com/porter-dev/porter/internal/billing"
 )
 
 func init() {
@@ -23,18 +23,20 @@ func init() {
 		key[i] = b
 	}
 
-	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
+	// TODO: delete once the billing code is cleaned up
 
-		InstanceBillingManager, err = eeBilling.NewClient(serverURL, publicServerURL, apiKey)
+	// 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
 
-		if err != nil {
-			panic(err)
-		}
-	} else {
-		InstanceBillingManager = &billing.NoopBillingManager{}
-	}
+	// 	InstanceBillingManager, err = eeBilling.NewClient(serverURL, publicServerURL, apiKey)
+
+	// 	if err != nil {
+	// 		panic(err)
+	// 	}
+	// } else {
+	// 	InstanceBillingManager = &billing.StripeBillingManager{}
+	// }
 }

+ 7 - 1
api/server/shared/config/loader/loader.go

@@ -63,7 +63,9 @@ func sharedInit() {
 		panic(err)
 	}
 
-	InstanceBillingManager = &billing.NoopBillingManager{}
+	InstanceBillingManager = &billing.StripeBillingManager{
+		StripeSecretKey: InstanceEnvConf.ServerConf.StripeSecretKey,
+	}
 }
 
 func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
@@ -251,6 +253,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 	res.LaunchDarklyClient = launchDarklyClient
 
+	if sc.StripeSecretKey == "" {
+		res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled")
+	}
+
 	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {
 		res.Logger.Info().Msg("Creating Slack client")
 		res.SlackConf = oauth.NewSlackClient(&oauth.Config{

+ 12 - 11
api/types/billing.go

@@ -1,15 +1,16 @@
 package types
 
-type AddProjectBillingRequest struct {
-	ProjectID uint `json:"project_id" form:"required"`
-
-	// Monthly price, in cents
-	Price uint `json:"price" form:"required"`
-
-	Users    uint `json:"users"`
-	Clusters uint `json:"clusters"`
-	CPU      uint `json:"cpu"`
-	Memory   uint `json:"memory"`
+// CreateBillingCustomerRequest is a request for creating a new billing customer.
+type CreateBillingCustomerRequest struct {
+	UserEmail string `json:"user_email" form:"required"`
+}
 
-	ExistingPlanName string `json:"existing_plan_name"`
+// PaymentMethod is a subset of the Stripe PaymentMethod type,
+// with only the fields used on the dashboard
+type PaymentMethod = struct {
+	ID           string `json:"id"`
+	DisplayBrand string `json:"display_brand"`
+	Last4        string `json:"last4"`
+	ExpMonth     int64  `json:"exp_month"`
+	ExpYear      int64  `json:"exp_year"`
 }

+ 3 - 0
api/types/project.go

@@ -14,6 +14,7 @@ type ProjectList struct {
 	APITokensEnabled       bool   `json:"api_tokens_enabled"`
 	StacksEnabled          bool   `json:"stacks_enabled"`
 	CapiProvisionerEnabled bool   `json:"capi_provisioner_enabled"`
+	BillingEnabled         bool   `json:"billing_enabled"`
 	DBEnabled              bool   `json:"db_enabled"`
 	SimplifiedViewEnabled  bool   `json:"simplified_view_enabled"`
 	AzureEnabled           bool   `json:"azure_enabled"`
@@ -37,6 +38,7 @@ type Project struct {
 	AzureEnabled                    bool    `json:"azure_enabled"`
 	BetaFeaturesEnabled             bool    `json:"beta_features_enabled"`
 	CapiProvisionerEnabled          bool    `json:"capi_provisioner_enabled"`
+	BillingEnabled                  bool    `json:"billing_enabled"`
 	DBEnabled                       bool    `json:"db_enabled"`
 	EFSEnabled                      bool    `json:"efs_enabled"`
 	EnableReprovision               bool    `json:"enable_reprovision"`
@@ -64,6 +66,7 @@ type Project struct {
 // retrieve feature flags from the `GET /projects/{project_id}` response instead
 type FeatureFlags struct {
 	AzureEnabled                    bool   `json:"azure_enabled,omitempty"`
+	BillingEnabled                  bool   `json:"billing_enabled,omitempty"`
 	CapiProvisionerEnabled          string `json:"capi_provisioner_enabled,omitempty"`
 	EnableReprovision               bool   `json:"enable_reprovision,omitempty"`
 	FullAddOns                      bool   `json:"full_add_ons,omitempty"`

+ 1 - 0
api/types/request.go

@@ -55,6 +55,7 @@ const (
 	URLParamAppRevisionID         URLParam = "app_revision_id"
 	URLParamDatastoreType         URLParam = "datastore_type"
 	URLParamDatastoreName         URLParam = "datastore_name"
+	URLParamPaymentMethodID       URLParam = "payment_method_id"
 	URLParamNotificationConfigID  URLParam = "notification_config_id"
 	URLParamNotificationID        URLParam = "notification_id"
 	URLParamCloudProviderType     URLParam = "cloud_provider_type"

+ 36 - 0
dashboard/package-lock.json

@@ -16,6 +16,8 @@
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
+        "@stripe/react-stripe-js": "^2.6.2",
+        "@stripe/stripe-js": "^3.0.10",
         "@tanstack/react-query": "^4.13.0",
         "@tanstack/react-query-devtools": "^4.13.5",
         "@visx/axis": "^3.3.0",
@@ -2944,6 +2946,27 @@
       "integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==",
       "dev": true
     },
+    "node_modules/@stripe/react-stripe-js": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.6.2.tgz",
+      "integrity": "sha512-FSjNg4v7BiCfojvx25PQ8DugOa09cGk1t816R/DLI/lT+1bgRAYpMvoPirLT4ZQ3ev/0VDtPdWNaabPsLDTOMA==",
+      "dependencies": {
+        "prop-types": "^15.7.2"
+      },
+      "peerDependencies": {
+        "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/@stripe/stripe-js": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.0.10.tgz",
+      "integrity": "sha512-CFRNha+aPXR8GrqJss2TbK1j4aSGZXQY8gx0hvaYiSp+dU7EK/Zs5uwFTSAgV+t8H4+jcZ/iBGajAvoMYOwy+A==",
+      "engines": {
+        "node": ">=12.16"
+      }
+    },
     "node_modules/@tanstack/match-sorter-utils": {
       "version": "8.7.6",
       "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz",
@@ -20202,6 +20225,19 @@
       "integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==",
       "dev": true
     },
+    "@stripe/react-stripe-js": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.6.2.tgz",
+      "integrity": "sha512-FSjNg4v7BiCfojvx25PQ8DugOa09cGk1t816R/DLI/lT+1bgRAYpMvoPirLT4ZQ3ev/0VDtPdWNaabPsLDTOMA==",
+      "requires": {
+        "prop-types": "^15.7.2"
+      }
+    },
+    "@stripe/stripe-js": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.0.10.tgz",
+      "integrity": "sha512-CFRNha+aPXR8GrqJss2TbK1j4aSGZXQY8gx0hvaYiSp+dU7EK/Zs5uwFTSAgV+t8H4+jcZ/iBGajAvoMYOwy+A=="
+    },
     "@tanstack/match-sorter-utils": {
       "version": "8.7.6",
       "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz",

+ 2 - 0
dashboard/package.json

@@ -11,6 +11,8 @@
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
+    "@stripe/react-stripe-js": "^2.6.2",
+    "@stripe/stripe-js": "^3.0.10",
     "@tanstack/react-query": "^4.13.0",
     "@tanstack/react-query-devtools": "^4.13.5",
     "@visx/axis": "^3.3.0",

+ 1 - 0
dashboard/src/assets/credit-card.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path fill="white" d="M64 32C28.7 32 0 60.7 0 96v32H576V96c0-35.3-28.7-64-64-64H64zM576 224H0V416c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V224zM112 352h64c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm112 16c0-8.8 7.2-16 16-16H368c8.8 0 16 7.2 16 16s-7.2 16-16 16H240c-8.8 0-16-7.2-16-16z"/></svg>

+ 15 - 0
dashboard/src/lib/billing/types.tsx

@@ -0,0 +1,15 @@
+import { z } from "zod";
+
+export type PaymentMethodList = PaymentMethod[];
+
+export type PaymentMethod = z.infer<typeof PaymentMethodValidator>;
+
+export const PaymentMethodValidator = z.object({
+  display_brand: z.string(),
+  id: z.string(),
+  last4: z.string(),
+  exp_month: z.number(),
+  exp_year: z.number(),
+});
+
+export const ClientSecretResponse = z.string();

+ 146 - 0
dashboard/src/lib/hooks/useStripe.tsx

@@ -0,0 +1,146 @@
+import { useContext, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import {
+  ClientSecretResponse,
+  PaymentMethodList,
+  PaymentMethodValidator,
+} from "lib/billing/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TUsePaymentMethod = {
+  paymentMethodList: PaymentMethodList;
+  refetchPaymentMethods: any;
+  isDeleting: boolean;
+  deletePaymentMethod: (paymentMethodId: string) => Promise<void>;
+};
+
+type TCreatePaymentMethod = {
+  createPaymentMethod: () => Promise<string>;
+};
+
+type TCheckHasPaymentEnabled = {
+  hasPaymentEnabled: boolean;
+  refetchPaymentEnabled: any;
+};
+
+export const usePaymentMethods = (): TUsePaymentMethod => {
+  const { user, currentProject } = useContext(Context);
+
+  // State has be shared so that payment methods can be removed
+  // from the Billing page once they are deleted
+  const [paymentMethodList, setPaymentMethodList] = useState<PaymentMethodList>(
+    []
+  );
+  const [isDeleting, setIsDeleting] = useState<boolean>(false);
+
+  // Fetch list of payment methods
+  const paymentMethodReq = useQuery(
+    ["getPaymentMethods", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      await api.checkBillingCustomerExists(
+        "<token>",
+        { user_email: user?.email },
+        { project_id: currentProject?.id }
+      );
+      const listResponse = await api.listPaymentMethod(
+        "<token>",
+        {},
+        { project_id: currentProject?.id }
+      );
+
+      const data = PaymentMethodValidator.array().parse(listResponse.data);
+      setPaymentMethodList(data);
+
+      return data;
+    }
+  );
+
+  // Delete list of payment methods
+  const deletePaymentMethod = async (paymentMethodId: string) => {
+    if (!currentProject?.id) {
+      throw new Error("Project ID is missing");
+    }
+    if (!paymentMethodId) {
+      throw new Error("Payment Method ID is missing");
+    }
+    setIsDeleting(true);
+
+    const resp = await api.deletePaymentMethod(
+      "<token>",
+      {},
+      { project_id: currentProject?.id, payment_method_id: paymentMethodId }
+    );
+    if (resp.status !== 200) {
+      throw new Error("Failed to delete payment method");
+    }
+
+    setPaymentMethodList(
+      paymentMethodList.filter(
+        (paymentMethod) => paymentMethod.id !== paymentMethodId
+      )
+    );
+    setIsDeleting(false);
+  };
+
+  return {
+    paymentMethodList,
+    refetchPaymentMethods: paymentMethodReq.refetch,
+    isDeleting,
+    deletePaymentMethod,
+  };
+};
+
+export const useCreatePaymentMethod = (): TCreatePaymentMethod => {
+  const { currentProject } = useContext(Context);
+
+  const createPaymentMethod = async () => {
+    const resp = await api.addPaymentMethod(
+      "<token>",
+      {},
+      { project_id: currentProject?.id }
+    );
+
+    const clientSecret = ClientSecretResponse.parse(resp.data);
+
+    return clientSecret;
+  };
+
+  return {
+    createPaymentMethod,
+  };
+};
+
+export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
+  const { currentProject } = useContext(Context);
+
+  if (!currentProject?.id) {
+    throw new Error("Project ID is missing");
+  }
+
+  // Fetch list of payment methods
+  const paymentEnabledReq = useQuery(
+    ["checkPaymentEnabled", currentProject?.id],
+    async () => {
+      const res = await api.getHasBilling(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      );
+
+      const data = z.boolean().parse(res.data);
+      return data;
+    }
+  );
+
+  return {
+    hasPaymentEnabled: paymentEnabledReq.data ?? false,
+    refetchPaymentEnabled: paymentEnabledReq.refetch,
+  };
+};

+ 1 - 54
dashboard/src/main/home/Home.tsx

@@ -1,4 +1,5 @@
 import React, { useContext, useEffect, useRef, useState } from "react";
+import { useStripe } from "@stripe/react-stripe-js";
 import { createPortal } from "react-dom";
 import {
   Route,
@@ -108,7 +109,6 @@ const Home: React.FC<Props> = (props) => {
     setHasFinishedOnboarding,
     setCurrentError,
     setCurrentModal,
-    setHasBillingEnabled,
     setUsage,
     setShouldRefreshClusters,
   } = useContext(Context);
@@ -262,46 +262,9 @@ const Home: React.FC<Props> = (props) => {
     }
   }, [shouldRefreshClusters]);
 
-  const checkIfProjectHasBilling = async (projectId: number) => {
-    if (!projectId) {
-      return false;
-    }
-    try {
-      const res = await api.getHasBilling(
-        "<token>",
-        {},
-        { project_id: projectId }
-      );
-      setHasBillingEnabled(res.data?.has_billing);
-      return res?.data?.has_billing;
-    } catch (error) {
-      console.log(error);
-    }
-  };
-
   useEffect(() => {
     getMetadata();
     checkOnboarding();
-    if (!process.env.DISABLE_BILLING) {
-      checkIfProjectHasBilling(currentProject?.id)
-        .then((isBillingEnabled) => {
-          if (isBillingEnabled) {
-            api
-              .getUsage("<token>", {}, { project_id: currentProject?.id })
-              .then((res) => {
-                const usage = res.data;
-                setUsage(usage);
-                /*
-                if (usage.exceeded) {
-                  setCurrentModal("UsageWarningModal", { usage });
-                }
-                */
-              })
-              .catch(console.log);
-          }
-        })
-        .catch(console.log);
-    }
   }, [props.currentProject?.id]);
 
   useEffect(() => {
@@ -411,22 +374,6 @@ const Home: React.FC<Props> = (props) => {
       theme={currentProject?.simplified_view_enabled ? midnight : standard}
     >
       <DeploymentTargetProvider>
-        {currentProject?.sandbox_enabled && (
-          <GlobalBanner>
-            <img src={warning} />
-            Your project is currently in Sandbox mode. Your project will be
-            deleted after one week.
-            <CTA>
-              <ShowIntercomButton
-                alt
-                message="I would like to eject to my own cloud account"
-                height="25px"
-              >
-                Request ejection
-              </ShowIntercomButton>
-            </CTA>
-          </GlobalBanner>
-        )}
         <StyledHome isHosted={currentProject?.sandbox_enabled ?? false}>
           <ModalHandler setRefreshClusters={setForceRefreshClusters} />
           {currentOverlay &&

+ 35 - 6
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -11,14 +11,17 @@ import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import PorterLink from "components/porter/Link";
+import Modal from "components/porter/Modal";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import DeleteEnvModal from "main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal";
+import BillingModal from "main/home/modals/BillingModal";
 import { clientAddonFromProto, type ClientAddon } from "lib/addons";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -43,6 +46,7 @@ const Apps: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
   const { currentDeploymentTarget } = useDeploymentTarget();
+  const { hasPaymentEnabled } = checkIfProjectHasPayment();
   const history = useHistory();
 
   const [searchValue, setSearchValue] = useState("");
@@ -50,6 +54,7 @@ const Apps: React.FC = () => {
   const [sort, setSort] = useState<"calendar" | "letter">("calendar");
   const [showDeleteEnvModal, setShowDeleteEnvModal] = useState(false);
   const [envDeleting, setEnvDeleting] = useState(false);
+  const [showBillingModal, setShowBillingModal] = useState(false);
 
   const [{ data: apps = [], status }, { data: addons = [] }] = useQueries({
     queries: [
@@ -212,20 +217,45 @@ const Apps: React.FC = () => {
           <Spacer y={0.5} />
           <Text color={"helper"}>Get started by creating an application.</Text>
           <Spacer y={1} />
-          <PorterLink to="/apps/new/app">
+          {currentProject?.billing_enabled && !hasPaymentEnabled ? (
             <Button
               alt
-              onClick={async () => {
-                await updateAppStep({ step: "stack-launch-start" });
+              onClick={() => {
+                setShowBillingModal(true);
               }}
               height="35px"
             >
-              Create a new application <Spacer inline x={1} />{" "}
+              Create a new application
+              <Spacer inline x={1} />{" "}
               <i className="material-icons" style={{ fontSize: "18px" }}>
                 east
               </i>
             </Button>
-          </PorterLink>
+          ) : (
+            <PorterLink to="/apps/new/app">
+              <Button
+                alt
+                onClick={async () => {
+                  await updateAppStep({ step: "stack-launch-start" });
+                }}
+                height="35px"
+              >
+                Create a new application
+                <Spacer inline x={1} />{" "}
+                <i className="material-icons" style={{ fontSize: "18px" }}>
+                  east
+                </i>
+              </Button>
+            </PorterLink>
+          )}
+          {showBillingModal && (
+            <BillingModal
+              back={() => setShowBillingModal(false)}
+              onCreate={() => {
+                history.push("/apps/new/app");
+              }}
+            />
+          )}
         </DashboardPlaceholder>
       );
     }
@@ -308,7 +338,6 @@ const Apps: React.FC = () => {
           ) : (
             <PorterLink to="/apps/new/app">
               <Button
-                disabled={currentProject?.sandbox_enabled && apps.length == 3}
                 onClick={async () => {
                   await updateAppStep({ step: "stack-launch-start" });
                 }}

+ 98 - 0
dashboard/src/main/home/modals/BillingModal.tsx

@@ -0,0 +1,98 @@
+import React from "react";
+import { Elements } from "@stripe/react-stripe-js";
+import { loadStripe } from "@stripe/stripe-js";
+import styled from "styled-components";
+
+import Heading from "components/form-components/Heading";
+import Link from "components/porter/Link";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import backArrow from "assets/back_arrow.png";
+
+import PaymentSetupForm from "./PaymentSetupForm";
+
+const stripePromise = loadStripe(process.env.STRIPE_PUBLISHABLE_KEY || "");
+
+const BillingModal = ({ back, onCreate }) => {
+  const appearance = {
+    variables: {
+      colorPrimary: "#aaaabb",
+      colorBackground: "#27292e",
+      colorText: "#fefefe",
+      fontFamily: "Work Sans",
+    },
+  };
+  const options = {
+    mode: "setup",
+    currency: "usd",
+    setupFutureUsage: "off_session",
+    paymentMethodTypes: ["card"],
+    appearance,
+    fonts: [
+      {
+        cssSrc: "https://fonts.googleapis.com/css?family=Work+Sans",
+      },
+    ],
+  };
+
+  return (
+    <Modal closeModal={back}>
+      <div id="checkout">
+        <Text size={16}>Add Payment Method</Text>
+        <Spacer y={1} />
+        <Text color="helper">
+          A payment method is required to begin deploying applications on
+          Porter. You can learn more about our pricing{" "}
+          <Link target="_blank" to="https://porter.run/pricing">
+            here
+          </Link>
+        </Text>
+        <Spacer y={1} />
+        <Elements
+          stripe={stripePromise}
+          options={options}
+          appearance={appearance}
+        >
+          <PaymentSetupForm onCreate={onCreate}></PaymentSetupForm>
+        </Elements>
+      </div>
+    </Modal>
+  );
+};
+
+export default BillingModal;
+
+const ControlRow = styled.div`
+  width: 100%;
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

+ 71 - 0
dashboard/src/main/home/modals/PaymentSetupForm.tsx

@@ -0,0 +1,71 @@
+import React, { useState } from "react";
+import {
+  PaymentElement,
+  useElements,
+  useStripe,
+} from "@stripe/react-stripe-js";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import SaveButton from "components/SaveButton";
+import { useCreatePaymentMethod } from "lib/hooks/useStripe";
+
+const PaymentSetupForm = ({ onCreate }: { onCreate: () => void }) => {
+  const stripe = useStripe();
+  const elements = useElements();
+
+  const [errorMessage, setErrorMessage] = useState(null);
+  const [loading, setLoading] = useState(false);
+  const { createPaymentMethod } = useCreatePaymentMethod();
+
+  const handleSubmit = async () => {
+    if (!stripe || !elements) {
+      return;
+    }
+
+    setLoading(true);
+
+    // Submit form before calling the server
+    const { error: submitError } = await elements.submit();
+    if (submitError) {
+      setLoading(false);
+      return;
+    }
+
+    // Create the setup intent in the server
+    const clientSecret = await createPaymentMethod();
+    console.log(clientSecret);
+
+    // Finally, confirm with Stripe so the payment method is saved
+    const { error } = await stripe.confirmSetup({
+      elements,
+      clientSecret,
+      redirect: "if_required",
+    });
+
+    if (error) {
+      setErrorMessage(error.message);
+    }
+
+    onCreate();
+  };
+
+  return (
+    <form>
+      <PaymentElement />
+      <Spacer y={1} />
+      <Button disabled={!stripe || loading} onClick={handleSubmit}>
+        Add payment method
+      </Button>
+      {errorMessage && <Error message={errorMessage}></Error>}
+    </form>
+  );
+};
+
+export default PaymentSetupForm;
+
+const SubmitButton = styled(SaveButton)`
+  position: initial;
+`;

+ 16 - 17
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -1,23 +1,16 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
-import { InviteType } from "shared/types";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+
 import api from "shared/api";
 import { Context } from "shared/Context";
 
-import Loading from "components/Loading";
-import InputRow from "components/form-components/InputRow";
-import Helper from "components/form-components/Helper";
-import Heading from "components/form-components/Heading";
-import CopyToClipboard from "components/CopyToClipboard";
-import { Column } from "react-table";
-import Table from "components/OldTable";
-import RadioSelector from "components/RadioSelector";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";
-import SaveButton from "components/SaveButton";
-
-type Props = {};
 
 export type APITokenMeta = {
   created_at: string;
@@ -33,11 +26,11 @@ export type APIToken = APITokenMeta & {
   token?: string;
 };
 
-const APITokensSection: React.FunctionComponent<Props> = ({}) => {
+const APITokensSection: React.FC = () => {
   const { currentProject } = useContext(Context);
 
   const [isLoading, setIsLoading] = useState(true);
-  const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
+  const [apiTokens, setAPITokens] = useState<APITokenMeta[]>([]);
   const [shouldCreate, setShouldCreate] = useState(false);
   const [expanded, setExpanded] = useState("");
 
@@ -64,8 +57,12 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
   if (shouldCreate) {
     return (
       <CreateAPITokenForm
-        onCreate={() => setShouldCreate(false)}
-        back={() => setShouldCreate(false)}
+        onCreate={() => {
+          setShouldCreate(false);
+        }}
+        back={() => {
+          setShouldCreate(false);
+        }}
       />
     );
   }
@@ -99,7 +96,9 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
         <SaveButton
           makeFlush={true}
           clearPosition={true}
-          onClick={() => setShouldCreate(true)}
+          onClick={() => {
+            setShouldCreate(true);
+          }}
         >
           <i className="material-icons">add</i>
           Create API Token

+ 120 - 60
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -1,69 +1,129 @@
-import React, { useContext, useEffect, useState } from "react";
-import { CustomerProvider, PlanSelect } from "@ironplans/react";
-import api from "shared/api";
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  checkIfProjectHasPayment,
+  usePaymentMethods,
+} from "lib/hooks/useStripe";
+
 import { Context } from "shared/Context";
+import cardIcon from "assets/credit-card.svg";
+import trashIcon from "assets/trash.png";
+
+import BillingModal from "../modals/BillingModal";
+
+function BillingPage(): JSX.Element {
+  const { setCurrentOverlay } = useContext(Context);
+  const [shouldCreate, setShouldCreate] = useState(false);
+  const {
+    paymentMethodList,
+    refetchPaymentMethods,
+    deletePaymentMethod,
+    isDeleting,
+  } = usePaymentMethods();
+
+  const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
-function BillingPage() {
-  const [customerToken, setCustomerToken] = useState("");
-  const [teamID, setTeamID] = useState("");
-  const { currentProject, setCurrentError, queryUsage } = useContext(Context);
+  const onCreate = async () => {
+    setShouldCreate(false);
+    refetchPaymentMethods();
+    refetchPaymentEnabled();
+  };
 
-  useEffect(() => {
-    let isSubscripted = true;
-    api
-      .getCustomerToken("<token>", {}, { project_id: currentProject?.id })
-      .then((res) => {
-        if (isSubscripted) {
-          const token = res?.data?.token;
-          const teamID = res?.data?.team_id;
-          setCustomerToken(token);
-          setTeamID(teamID);
-        }
-      })
-      .catch((err) => {
-        setCurrentError(err);
-      });
-    return () => {
-      isSubscripted = false;
-      queryUsage();
-    };
-  }, [currentProject?.id]);
+  const onDelete = async (paymentMethodId: string) => {
+    deletePaymentMethod(paymentMethodId);
+    refetchPaymentEnabled();
+  };
+
+  if (shouldCreate) {
+    return (
+      <BillingModal onCreate={onCreate} back={() => setShouldCreate(false)} />
+    );
+  }
 
   return (
-    <div style={{ height: "1000px" }}>
-      <CustomerProvider token={customerToken} teamId={teamID}>
-        <PlanSelect
-          theme={{
-            base: {
-              customFont: "Work Sans",
-              fontFamily: '"Work Sans", sans-serif',
-              darkMode: "on",
-              colors: {
-                primary: "rgba(97, 111, 238, 0.8)",
-                secondary: "rgb(103, 108, 124)",
-                danger: "rgb(227, 54, 109)",
-                success: "rgb(56, 168, 138)",
-              },
-            },
-            card: {
-              backgroundColor: "rgb(38, 40, 47)",
-              boxShadow: "rgb(0 0 0 / 33%) 0px 4px 15px 0px",
-              borderRadius: "8px",
-              border: "2px solid rgba(158, 180, 255, 0)",
-            },
-            button: {
-              base: {
-                boxShadow: "rgb(0 0 0 / 19%) 0px 2px 5px 0px",
-                borderRadius: "5px",
-                fontSize: "14px",
-                fontWeight: "500",
-              },
-            },
-          }}
-        ></PlanSelect>
-      </CustomerProvider>
-    </div>
+    <>
+      <Text size={16}>Payment methods</Text>
+      <Spacer y={1} />
+      <Text color="helper">
+        Manage the payment methods associated with this project.
+      </Text>
+      <Spacer y={1} />
+      {paymentMethodList.map((paymentMethod, idx) => {
+        return (
+          <>
+            <Fieldset key={idx}>
+              <Container row spaced>
+                <Container row>
+                  <Icon src={cardIcon} height={"14px"} />
+                  <Spacer inline x={1} />
+                  <Text color="helper">
+                    **** **** **** {paymentMethod.last4}
+                  </Text>
+                  <Spacer inline x={1} />
+                  <Text color="helper">
+                    Expires: {paymentMethod.exp_month}/{paymentMethod.exp_year}
+                  </Text>
+                  <Spacer inline x={1} />
+                </Container>
+                <DeleteButtonContainer>
+                  {isDeleting ? (
+                    <Loading />
+                  ) : (
+                    <DeleteButton
+                      onClick={() => {
+                        setCurrentOverlay({
+                          message: `Are you sure you want to remove this payment method?`,
+                          onYes: () => {
+                            deletePaymentMethod(paymentMethod.id);
+                            setCurrentOverlay(null);
+                          },
+                          onNo: () => {
+                            setCurrentOverlay(null);
+                          },
+                        });
+                      }}
+                    >
+                      <Icon src={trashIcon} height={"18px"} />
+                    </DeleteButton>
+                  )}
+                </DeleteButtonContainer>
+              </Container>
+            </Fieldset>
+            <Spacer y={1} />
+          </>
+        );
+      })}
+      <Button
+        onClick={() => {
+          setShouldCreate(true);
+        }}
+      >
+        <I className="material-icons">add</I>
+        Add Payment Method
+      </Button>
+    </>
   );
 }
 
 export default BillingPage;
+
+const I = styled.i`
+  font-size: 18px;
+  margin-right: 10px;
+`;
+
+const DeleteButton = styled.div`
+  cursor: pointer;
+`;
+
+const DeleteButtonContainer = styled.div`
+  text-align: center;
+`;

+ 10 - 25
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -25,6 +25,7 @@ import settingsGrad from "assets/settings-grad.svg";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import APITokensSection from "./APITokensSection";
+import BillingPage from "./BillingPage";
 import InvitePage from "./InviteList";
 import Metadata from "./Metadata";
 import ProjectDeleteConsent from "./ProjectDeleteConsent";
@@ -74,20 +75,9 @@ function ProjectSettings(props: any) {
 
     const tabOpts = [];
     tabOpts.push({ value: "manage-access", label: "Manage access" });
-    // ? Disabled for now https://discord.com/channels/542888846271184896/1059277393031856208/1059277395913351258
-    // tabOptions.push({
-    //   value: "billing",
-    //   label: "Billing",
-    // });
     tabOpts.push({ value: "metadata", label: "Metadata" });
-    if (props.isAuthorized("settings", "", ["get", "delete"])) {
-      // if (this.context?.hasBillingEnabled) {
-      //   tabOptions.push({
-      //     value: "billing",
-      //     label: "Billing",
-      //   });
-      // }
 
+    if (props.isAuthorized("settings", "", ["get", "delete"])) {
       if (currentProject?.api_tokens_enabled) {
         tabOpts.push({
           value: "api-tokens",
@@ -95,6 +85,13 @@ function ProjectSettings(props: any) {
         });
       }
 
+      if (currentProject?.billing_enabled) {
+        tabOpts.push({
+          value: "billing",
+          label: "Billing",
+        });
+      }
+
       tabOpts.push({
         value: "additional-settings",
         label: "Additional settings",
@@ -171,19 +168,7 @@ function ProjectSettings(props: any) {
     } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
     } else if (currentTab === "billing") {
-      return (
-        <Placeholder>
-          <Helper>
-            Visit the{" "}
-            <a
-              href={`/api/projects/${context.currentProject?.id}/billing/redirect`}
-            >
-              billing portal
-            </a>{" "}
-            to view plans.
-          </Helper>
-        </Placeholder>
-      );
+      return <BillingPage></BillingPage>;
     } else {
       return (
         <>

+ 66 - 12
dashboard/src/shared/api.tsx

@@ -2574,17 +2574,6 @@ const getUsage = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/usage`
 );
 
-// Used for billing purposes
-const getCustomerToken = baseApi<{}, { project_id: number }>(
-  "GET",
-  ({ project_id }) => `/api/projects/${project_id}/billing/token`
-);
-
-const getHasBilling = baseApi<{}, { project_id: number }>(
-  "GET",
-  ({ project_id }) => `/api/projects/${project_id}/billing`
-);
-
 const getOnboardingState = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/onboarding`
@@ -3437,6 +3426,65 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
+// Billing
+const checkBillingCustomerExists = baseApi<
+  {
+    user_email?: string;
+  },
+  {
+    project_id?: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/billing/customer`);
+
+const getHasBilling = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing`
+);
+
+const listPaymentMethod = baseApi<
+  {},
+  {
+    project_id?: number;
+  }
+>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing/payment_method`
+);
+
+const addPaymentMethod = baseApi<
+  {},
+  {
+    project_id?: number;
+  }
+>(
+  "POST",
+  ({ project_id }) => `/api/projects/${project_id}/billing/payment_method`
+);
+
+const updatePaymentMethod = baseApi<
+  {},
+  {
+    project_id?: number;
+    payment_method_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, payment_method_id }) =>
+    `/api/projects/${project_id}/billing/payment_method/${payment_method_id}`
+);
+
+const deletePaymentMethod = baseApi<
+  {},
+  {
+    project_id?: number;
+    payment_method_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, payment_method_id }) =>
+    `/api/projects/${project_id}/billing/payment_method/${payment_method_id}`
+);
+
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
@@ -3701,7 +3749,6 @@ export default {
   getPolicyDocument,
   createWebhookToken,
   getUsage,
-  getCustomerToken,
   getHasBilling,
   getOnboardingState,
   saveOnboardingState,
@@ -3782,6 +3829,13 @@ export default {
   addStackEnvGroup,
   removeStackEnvGroup,
 
+  // BILLING
+  checkBillingCustomerExists,
+  listPaymentMethod,
+  addPaymentMethod,
+  updatePaymentMethod,
+  deletePaymentMethod,
+
   // STATUS
   getGithubStatus,
   getCloudProviderPermissionsStatus,

+ 7 - 6
dashboard/src/shared/types.tsx

@@ -289,15 +289,15 @@ export type FormElement = {
 export type RepoType = {
   FullName: string;
 } & (
-  | {
+    | {
       Kind: "github";
       GHRepoID: number;
     }
-  | {
+    | {
       Kind: "gitlab";
       GitIntegrationId: number;
     }
-);
+  );
 
 export type FileType = {
   path: string;
@@ -315,6 +315,7 @@ export type ProjectType = {
   api_tokens_enabled: boolean;
   azure_enabled: boolean;
   beta_features_enabled: boolean;
+  billing_enabled: boolean;
   capi_provisioner_enabled: boolean;
   db_enabled: boolean;
   efs_enabled: boolean;
@@ -376,15 +377,15 @@ export type ActionConfigType = {
   image_repo_uri: string;
   dockerfile_path?: string;
 } & (
-  | {
+    | {
       kind: "gitlab";
       gitlab_integration_id: number;
     }
-  | {
+    | {
       kind: "github";
       git_repo_id: number;
     }
-);
+  );
 
 export type GithubActionConfigType = ActionConfigType & {
   kind: "github";

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

@@ -1,117 +0,0 @@
-package billing
-
-import (
-	"errors"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"strconv"
-
-	"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, features, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// newUsage will be nil if webhook event type is not "subscription", so return without
-	// updating usage in this case
-	if newUsage == nil {
-		return
-	}
-
-	// update the project's usage
-	existingUsage, 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 {
-		newUsage.ID = existingUsage.ID
-		_, err = c.Repo().ProjectUsage().UpdateProjectUsage(newUsage)
-	}
-
-	if err != nil {
-		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
-	}
-
-	if capiProvisionerEnabled, err := strconv.ParseBool(features.CapiProvisionerEnabled); err == nil {
-		project.CapiProvisionerEnabled = capiProvisionerEnabled
-	}
-
-	_, err = c.Repo().Project().UpdateProject(project)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-}

+ 0 - 284
ee/billing/client.go

@@ -1,284 +0,0 @@
-//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,
-			CapiProvisionerEnabled:     usageData.CapiProvisionerEnabled,
-			SimplifiedViewEnabled:      usageData.SimplifiedViewEnabled,
-			AzureEnabled:               usageData.AzureEnabled,
-		}, nil
-}

+ 0 - 50
ee/billing/types.go

@@ -1,50 +0,0 @@
-//go:build ee
-// +build ee
-
-package billing
-
-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 DeleteCustomerRequest struct {
-	UserID    uint `json:"user_id" form:"required"`
-	ProjectID uint `json:"project_id" form:"required"`
-}
-
-type APIWebhookRequest struct {
-	ProjectID uint `json:"project_id" form:"required"`
-
-	Clusters uint `json:"clusters" form:"required"`
-	Users    uint `json:"users" form:"required"`
-	CPU      uint `json:"cpu" form:"required"`
-	Memory   uint `json:"memory" form:"required"`
-
-	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"`
-	CapiProvisionerEnabled     string `json:"capi_provisioner_enabled,omitempty"`
-	SimplifiedViewEnabled      string `json:"simplified_view_enabled,omitempty"`
-	AzureEnabled               bool   `json:"azure_enabled,omitempty"`
-}
-
-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 CreateBillingCookieResponse struct {
-	Token   string `json:"token"`
-	TokenID string `json:"token_id"`
-}
-
-type VerifyUserRequest struct {
-	TokenID string `schema:"token_id" form:"required"`
-	Token   string `schema:"token" form:"required"`
-}

+ 1 - 0
go.mod

@@ -88,6 +88,7 @@ require (
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
+	github.com/stripe/stripe-go/v76 v76.21.0
 	github.com/xanzy/go-gitlab v0.68.0
 	go.opentelemetry.io/otel v1.16.0
 	go.opentelemetry.io/otel/sdk v1.16.0

+ 2 - 0
go.sum

@@ -1753,6 +1753,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stripe/stripe-go/v76 v76.21.0 h1:O3GHImHS4oUI3qWMOClHN3zAQF5/oswS/NB7leV1fsU=
+github.com/stripe/stripe-go/v76 v76.21.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/sylvia7788/contextcheck v1.0.4/go.mod h1:vuPKJMQ7MQ91ZTqfdyreNKwZjyUg6KO+IebVyQDedZQ=

+ 33 - 24
internal/billing/billing.go

@@ -1,52 +1,61 @@
 package billing
 
 import (
-	"fmt"
-
 	"github.com/porter-dev/porter/api/types"
 	"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(user *models.User, proj *models.Project) (teamID string, err error)
+	// CreateCustomer registers a project in the billing provider. This is currently a one-to-one
+	// mapping with projects and billing customers, because billing and usage are set per project.
+	CreateCustomer(userEmail string, proj *models.Project) (customerID string, err error)
+
+	// DeleteCustomer will delete the customer from the billing provider
+	DeleteCustomer(proj *models.Project) (err error)
 
-	// DeleteTeam deletes a billing team.
-	DeleteTeam(user *models.User, proj *models.Project) (err error)
+	// CheckPaymentEnabled will check if the project has a payment method configured
+	CheckPaymentEnabled(proj *models.Project) (paymentEnabled bool, 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)
+	// ListPaymentMethod will return all payment methods for the project
+	ListPaymentMethod(proj *models.Project) (paymentMethods []types.PaymentMethod, err error)
 
-	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
-	// from a billing agent
-	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error)
+	// CreatePaymentMethod will add a new payment method to the project in Stripe
+	CreatePaymentMethod(proj *models.Project) (clientSecret string, err error)
 
-	// VerifySignature verifies the signature for a webhook
-	VerifySignature(signature string, body []byte) bool
+	// DeletePaymentMethod will remove a payment method for the project in Stripe
+	DeletePaymentMethod(paymentMethodID string) (err error)
 }
 
 // NoopBillingManager performs no billing operations
 type NoopBillingManager struct{}
 
-func (n *NoopBillingManager) CreateTeam(user *models.User, proj *models.Project) (teamID string, err error) {
-	return fmt.Sprintf("%d", proj.ID), nil
+// CreateCustomer is a no-op
+func (s *NoopBillingManager) CreateCustomer(userEmail string, proj *models.Project) (customerID string, err error) {
+	return "", nil
 }
 
-func (n *NoopBillingManager) DeleteTeam(user *models.User, proj *models.Project) (err error) {
+// DeleteCustomer is a no-op
+func (s *NoopBillingManager) DeleteCustomer(proj *models.Project) (err error) {
 	return nil
 }
 
-func (n *NoopBillingManager) GetRedirectURI(user *models.User, proj *models.Project) (url string, err error) {
-	return "", nil
+// CheckPaymentEnabled is a  no-op
+func (s *NoopBillingManager) CheckPaymentEnabled(proj *models.Project) (paymentEnabled bool, err error) {
+	return false, nil
 }
 
-func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, *types.FeatureFlags, error) {
-	return nil, nil, nil
+// ListPaymentMethod is a no-op
+func (s *NoopBillingManager) ListPaymentMethod(proj *models.Project) (paymentMethods []types.PaymentMethod, err error) {
+	return []types.PaymentMethod{}, nil
 }
 
-func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {
-	return false
+// CreatePaymentMethod is a no-op
+func (s *NoopBillingManager) CreatePaymentMethod(proj *models.Project) (clientSecret string, err error) {
+	return "", nil
+}
+
+// DeletePaymentMethod is a no-op
+func (s *NoopBillingManager) DeletePaymentMethod(paymentMethodID string) (err error) {
+	return nil
 }

+ 127 - 0
internal/billing/stripe.go

@@ -0,0 +1,127 @@
+package billing
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/stripe/stripe-go/v76"
+	"github.com/stripe/stripe-go/v76/customer"
+	"github.com/stripe/stripe-go/v76/paymentmethod"
+	"github.com/stripe/stripe-go/v76/setupintent"
+)
+
+// StripeBillingManager interacts with the Stripe API to manage payment methods
+// and customers
+type StripeBillingManager struct {
+	StripeSecretKey string
+}
+
+// CreateCustomer will create a customer in Stripe only if the project doesn't have a BillingID
+func (s *StripeBillingManager) CreateCustomer(userEmail string, proj *models.Project) (customerID string, err error) {
+	stripe.Key = s.StripeSecretKey
+
+	if proj.BillingID == "" {
+		// Create customer if not exists
+		customerName := fmt.Sprintf("project_%s", proj.Name)
+		params := &stripe.CustomerParams{
+			Name:  stripe.String(customerName),
+			Email: stripe.String(userEmail),
+		}
+
+		// Create in Stripe
+		customer, err := customer.New(params)
+		if err != nil {
+			return "", err
+		}
+
+		customerID = customer.ID
+	}
+
+	return customerID, nil
+}
+
+// DeleteCustomer will delete the customer from the billing provider
+func (s *StripeBillingManager) DeleteCustomer(proj *models.Project) (err error) {
+	stripe.Key = s.StripeSecretKey
+
+	if proj.BillingID != "" {
+		params := &stripe.CustomerParams{}
+		_, err := customer.Del(proj.BillingID, params)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// CheckPaymentEnabled will return true if the project has a payment method added, false otherwise
+func (s *StripeBillingManager) CheckPaymentEnabled(proj *models.Project) (paymentEnabled bool, err error) {
+	stripe.Key = s.StripeSecretKey
+
+	params := &stripe.PaymentMethodListParams{
+		Customer: stripe.String(proj.BillingID),
+		Type:     stripe.String(string(stripe.PaymentMethodTypeCard)),
+	}
+	result := paymentmethod.List(params)
+
+	return result.Next(), nil
+}
+
+// ListPaymentMethod will return all payment methods for the project
+func (s *StripeBillingManager) ListPaymentMethod(proj *models.Project) (paymentMethods []types.PaymentMethod, err error) {
+	stripe.Key = s.StripeSecretKey
+
+	params := &stripe.PaymentMethodListParams{
+		Customer: stripe.String(proj.BillingID),
+		Type:     stripe.String(string(stripe.PaymentMethodTypeCard)),
+	}
+	result := paymentmethod.List(params)
+
+	for result.Next() {
+		stripePaymentMethod := result.PaymentMethod()
+
+		paymentMethods = append(paymentMethods, types.PaymentMethod{
+			ID:           stripePaymentMethod.ID,
+			DisplayBrand: stripePaymentMethod.Card.DisplayBrand,
+			Last4:        stripePaymentMethod.Card.Last4,
+			ExpMonth:     stripePaymentMethod.Card.ExpMonth,
+			ExpYear:      stripePaymentMethod.Card.ExpYear,
+		})
+	}
+
+	return paymentMethods, nil
+}
+
+// CreatePaymentMethod will add a new payment method to the project in Stripe
+func (s *StripeBillingManager) CreatePaymentMethod(proj *models.Project) (clientSecret string, err error) {
+	stripe.Key = s.StripeSecretKey
+
+	params := &stripe.SetupIntentParams{
+		Customer: stripe.String(proj.BillingID),
+		AutomaticPaymentMethods: &stripe.SetupIntentAutomaticPaymentMethodsParams{
+			Enabled: stripe.Bool(false),
+		},
+		PaymentMethodTypes: []*string{stripe.String("card")},
+	}
+
+	intent, err := setupintent.New(params)
+	if err != nil {
+		return "", err
+	}
+
+	return intent.ClientSecret, nil
+}
+
+// DeletePaymentMethod will remove a payment method for the project in Stripe
+func (s *StripeBillingManager) DeletePaymentMethod(paymentMethodID string) (err error) {
+	stripe.Key = s.StripeSecretKey
+
+	_, err = paymentmethod.Detach(paymentMethodID, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 12 - 0
internal/models/project.go

@@ -25,6 +25,9 @@ const (
 	// CapiProvisionerEnabled enables the CAPI Provisioning flow
 	CapiProvisionerEnabled FeatureFlagLabel = "capi_provisioner_enabled"
 
+	// BillingEnabled enables the "Billing" tab and all Stripe integrations
+	BillingEnabled FeatureFlagLabel = "billing_enabled"
+
 	// DBEnabled enables the "Databases" tab
 	DBEnabled FeatureFlagLabel = "db_enabled"
 
@@ -93,6 +96,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	AzureEnabled:                    false,
 	BetaFeaturesEnabled:             false,
 	CapiProvisionerEnabled:          true,
+	BillingEnabled:                  false,
 	DBEnabled:                       false,
 	EFSEnabled:                      false,
 	EnableReprovision:               false,
@@ -129,6 +133,10 @@ type Project struct {
 	Name  string `json:"name"`
 	Roles []Role `json:"roles"`
 
+	// BillingID corresponds to the id generated by the billing provider
+	BillingID      string
+	BillingEnabled bool
+
 	ProjectUsageID      uint
 	ProjectUsageCacheID uint
 
@@ -220,6 +228,8 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return p.AzureEnabled
 		case "capi_provisioner_enabled":
 			return p.CapiProvisionerEnabled
+		case "billing_enabled":
+			return p.BillingEnabled
 		case "db_enabled":
 			return false
 		case "enable_reprovision":
@@ -288,6 +298,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		AzureEnabled:                    p.GetFeatureFlag(AzureEnabled, launchDarklyClient),
 		BetaFeaturesEnabled:             p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
 		CapiProvisionerEnabled:          p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient),
+		BillingEnabled:                  p.GetFeatureFlag(BillingEnabled, launchDarklyClient),
 		DBEnabled:                       p.GetFeatureFlag(DBEnabled, launchDarklyClient),
 		EFSEnabled:                      p.GetFeatureFlag(EFSEnabled, launchDarklyClient),
 		EnableReprovision:               p.GetFeatureFlag(EnableReprovision, launchDarklyClient),
@@ -327,6 +338,7 @@ func (p *Project) ToProjectListType() *types.ProjectList {
 		// note: all of these fields should be considered deprecated
 		// in an api response
 		Roles:                  roles,
+		BillingEnabled:         p.BillingEnabled,
 		PreviewEnvsEnabled:     p.PreviewEnvsEnabled,
 		RDSDatabasesEnabled:    p.RDSDatabasesEnabled,
 		ManagedInfraEnabled:    p.ManagedInfraEnabled,

+ 3 - 0
zarf/helm/.dashboardenv

@@ -19,3 +19,6 @@ API_SERVER=http://localhost:8080
 # TRUST_ARN is used with the cloudformation pack, to allow supporting multiple AWS accounts as management accounts. Change MY_AWS_DEV_ACCOUNT_ID to your AWS developer account ID
 
 TRUST_ARN=arn:aws:iam::MY_AWS_DEV_ACCOUNT_ID:role/CAPIManagement
+
+# STRIPE_PUBLISHABLE_KEY is used to create Stripe Web Elements
+STRIPE_PUBLISHABLE_KEY=

+ 3 - 0
zarf/helm/.serverenv

@@ -66,3 +66,6 @@ HELM_APP_REPO_URL=http://chartmuseum:8080
 
 TELEMETRY_NAME=porter
 TELEMETRY_COLLECTOR_URL=otel-collector:4317
+
+# STRIPE_SECRET_KEY is required if billing is enabled
+STRIPE_SECRET_KEY=