Przeglądaj źródła

Merge branch 'metronome-integration' into fix-duplicate-customers-2

Mauricio Araujo 2 lat temu
rodzic
commit
f72458db6c

+ 2 - 2
api/server/handlers/billing/create.go

@@ -42,7 +42,7 @@ func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	clientSecret, err := c.Config().BillingManager.CreatePaymentMethod(ctx, proj.BillingID)
+	clientSecret, err := c.Config().BillingManager.StripeClient.CreatePaymentMethod(ctx, proj.BillingID)
 	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)))
@@ -81,7 +81,7 @@ func (c *SetDefaultBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err := c.Config().BillingManager.SetDefaultPaymentMethod(ctx, paymentMethodID, proj.BillingID)
+	err := c.Config().BillingManager.StripeClient.SetDefaultPaymentMethod(ctx, paymentMethodID, proj.BillingID)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error setting default payment method")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error setting default payment method: %w", err)))

+ 48 - 0
api/server/handlers/billing/credits.go

@@ -0,0 +1,48 @@
+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/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"
+)
+
+// GetCreditsHandler is a handler for getting available credits
+type GetCreditsHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+// NewGetCreditsHandler will create a new GetCreditsHandler
+func NewGetCreditsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetCreditsHandler {
+	return &GetCreditsHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "get-credits-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+	}
+
+	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerCredits(proj.UsageID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting credits")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, credits)
+}

+ 1 - 1
api/server/handlers/billing/delete.go

@@ -44,7 +44,7 @@ func (c *DeleteBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err := c.Config().BillingManager.DeletePaymentMethod(ctx, paymentMethodID)
+	err := c.Config().BillingManager.StripeClient.DeletePaymentMethod(ctx, 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)))

+ 1 - 21
api/server/handlers/billing/key.go

@@ -5,7 +5,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"
@@ -33,30 +32,11 @@ func (c *GetPublishableKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	defer span.End()
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
-	user, _ := ctx.Value(types.UserScope).(*models.User)
-
-	// Create billing customer for project and set the billing ID if it doesn't exist
-	if proj.BillingID == "" {
-		billingID, err := c.Config().BillingManager.CreateCustomer(ctx, user.Email, proj.ID, proj.Name)
-		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating billing customer")
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-		proj.BillingID = billingID
-
-		_, err = c.Repo().Project().UpdateProject(proj)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error updating project")
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
 
 	// There is no easy way to pass environment variables to the frontend,
 	// so for now pass via the backend. This is acceptable because the key is
 	// meant to be public
-	publishableKey := c.Config().BillingManager.GetPublishableKey(ctx)
+	publishableKey := c.Config().BillingManager.StripeClient.GetPublishableKey(ctx)
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "project-id", Value: proj.ID},

+ 46 - 20
api/server/handlers/billing/list.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/google/uuid"
 	"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"
@@ -40,25 +41,7 @@ func (c *ListBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	user, _ := ctx.Value(types.UserScope).(*models.User)
 
-	// Create billing customer for project and set the billing ID if it doesn't exist
-	if proj.BillingID == "" {
-		billingID, err := c.Config().BillingManager.CreateCustomer(ctx, user.Email, proj.ID, proj.Name)
-		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating billing customer")
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-		proj.BillingID = billingID
-
-		_, err = c.Repo().Project().UpdateProject(proj)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error updating project")
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-	}
-
-	paymentMethods, err := c.Config().BillingManager.ListPaymentMethod(ctx, proj.BillingID)
+	paymentMethods, err := c.Config().BillingManager.StripeClient.ListPaymentMethod(ctx, proj.BillingID)
 	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)))
@@ -83,8 +66,51 @@ func (c *CheckPaymentEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	defer span.End()
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+
+	// Create billing customer for project and set the billing ID if it doesn't exist
+	var shouldUpdate bool
+	if proj.BillingID == "" {
+		billingID, err := c.Config().BillingManager.StripeClient.CreateCustomer(ctx, user.Email, proj.ID, proj.Name)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating billing customer")
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+		proj.BillingID = billingID
+		shouldUpdate = true
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID},
+		)
+	}
+
+	if proj.UsageID == uuid.Nil {
+		customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(user.CompanyName, proj.Name, proj.ID, proj.BillingID, c.Config().ServerConf.PorterCloudPlanID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating Metronome customer")
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+		proj.UsageID = customerID
+		proj.UsagePlanID = customerPlanID
+		shouldUpdate = true
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+			telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
+		)
+	}
+
+	if shouldUpdate {
+		_, err := c.Repo().Project().UpdateProject(proj)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error updating project")
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
-	paymentEnabled, err := c.Config().BillingManager.CheckPaymentEnabled(ctx, proj.BillingID)
+	paymentEnabled, err := c.Config().BillingManager.StripeClient.CheckPaymentEnabled(ctx, proj.BillingID)
 	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)))

+ 7 - 6
api/server/handlers/cluster/install_agent.go

@@ -106,11 +106,13 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	porterAgentValues := map[string]interface{}{
 		"agent": map[string]interface{}{
-			"porterHost":  c.Config().ServerConf.ServerURL,
-			"porterPort":  "443",
-			"porterToken": encoded,
-			"clusterID":   fmt.Sprintf("%d", cluster.ID),
-			"projectID":   fmt.Sprintf("%d", proj.ID),
+			"porterHost":    c.Config().ServerConf.ServerURL,
+			"porterPort":    "443",
+			"porterToken":   encoded,
+			"clusterID":     fmt.Sprintf("%d", cluster.ID),
+			"projectID":     fmt.Sprintf("%d", proj.ID),
+			"prometheusURL": c.Config().ServerConf.PrometheusUrl,
+			"metronomeKey":  c.Config().ServerConf.MetronomeAPIKey,
 		},
 		"loki": map[string]interface{}{},
 	}
@@ -185,7 +187,6 @@ func checkAndDeleteOlderAgent(k8sAgent *kubernetes.Agent, helmAgent *helm.Agent)
 	}
 
 	_, err = helmAgent.UninstallChart(context.Background(), "porter-agent")
-
 	if err != nil {
 		return err
 	}

+ 25 - 1
api/server/handlers/project/create.go

@@ -3,6 +3,7 @@ package project
 import (
 	"net/http"
 
+	"github.com/google/uuid"
 	"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"
@@ -79,16 +80,39 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// Create Stripe Customer
 	if p.Config().ServerConf.StripeSecretKey != "" && p.Config().ServerConf.StripePublishableKey != "" {
 		// Create billing customer for project and set the billing ID
-		billingID, err := p.Config().BillingManager.CreateCustomer(ctx, user.Email, proj.ID, proj.Name)
+		billingID, err := p.Config().BillingManager.StripeClient.CreateCustomer(ctx, user.Email, proj.ID, proj.Name)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating billing customer")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
 		proj.BillingID = billingID
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
+			telemetry.AttributeKV{Key: "customer-id", Value: proj.BillingID},
+			telemetry.AttributeKV{Key: "user-email", Value: user.Email},
+		)
+	}
+
+	// Create Metronome customer and add to starter plan
+	if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
+		customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(user.CompanyName, proj.Name, proj.ID, proj.BillingID, p.Config().ServerConf.PorterCloudPlanID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating Metronome customer")
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		}
+		proj.UsageID = customerID
+		proj.UsagePlanID = customerPlanID
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+			telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
+		)
+	}
 
+	if proj.BillingID != "" || proj.UsageID != uuid.Nil {
 		_, err = p.Repo().Project().UpdateProject(proj)
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error updating project")

+ 9 - 6
api/server/handlers/project/delete.go

@@ -92,12 +92,15 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = p.Config().BillingManager.DeleteCustomer(ctx, proj.BillingID)
-	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
+	if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" &&
+		proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
+		err = p.Config().BillingManager.MetronomeClient.EndCustomerPlan(proj.UsageID, proj.UsagePlanID)
+		if err != nil {
+			e := "error ending billing plan"
+			err = telemetry.Error(ctx, span, err, e)
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 
 	deletedProject, err := p.Repo().Project().DeleteProject(proj)

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

@@ -341,6 +341,33 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing/credits -> project.NewGetCreditsHandler
+	getCreditsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/credits",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getCreditsHandler := billing.NewGetCreditsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getCreditsEndpoint,
+		Handler:  getCreditsHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/billing/payment_method -> project.NewCreateBillingHandler
 	createBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 1
api/server/shared/apitest/config.go

@@ -60,7 +60,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 		UserNotifier:       notifier,
 		LaunchDarklyClient: &features.Client{},
 		AnalyticsClient:    analytics.InitializeAnalyticsSegmentClient("", l),
-		BillingManager:     &billing.NoopBillingManager{},
+		BillingManager:     billing.Manager{},
 		TelemetryConfig:    telemetry.TracerConfig{ServiceName: "fake", CollectorURL: "fake"},
 	}, nil
 }

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

@@ -92,7 +92,7 @@ type Config struct {
 	AnalyticsClient analytics.AnalyticsSegmentClient
 
 	// BillingManager manages billing for Porter instances with billing enabled
-	BillingManager billing.BillingManager
+	BillingManager billing.Manager
 
 	// WhitelistedUsers do not count toward usage limits
 	WhitelistedUsers map[uint]uint

+ 9 - 2
api/server/shared/config/env/envconfs.go

@@ -71,8 +71,15 @@ type ServerConf struct {
 
 	StripeSecretKey      string `env:"STRIPE_SECRET_KEY"`
 	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
-	SlackClientID        string `env:"SLACK_CLIENT_ID"`
-	SlackClientSecret    string `env:"SLACK_CLIENT_SECRET"`
+	MetronomeAPIKey      string `env:"METRONOME_API_KEY"`
+	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
+
+	// This endpoint will be passed to the porter-agent so that
+	// the billing manager can query Prometheus.
+	PrometheusUrl string `env:"PROMETHEUS_URL"`
+
+	SlackClientID     string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 
 	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
 	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`

+ 27 - 13
api/server/shared/config/loader/loader.go

@@ -40,9 +40,10 @@ import (
 )
 
 var (
-	InstanceBillingManager billing.BillingManager
-	InstanceEnvConf        *envloader.EnvConf
-	InstanceDB             *pgorm.DB
+	// InstanceEnvConf holds the environment configuration
+	InstanceEnvConf *envloader.EnvConf
+	// InstanceDB holds the config for connecting to the database
+	InstanceDB *pgorm.DB
 )
 
 type EnvConfigLoader struct {
@@ -61,11 +62,6 @@ func sharedInit() {
 	if err != nil {
 		panic(err)
 	}
-
-	InstanceBillingManager = &billing.StripeBillingManager{
-		StripeSecretKey:      InstanceEnvConf.ServerConf.StripeSecretKey,
-		StripePublishableKey: InstanceEnvConf.ServerConf.StripePublishableKey,
-	}
 }
 
 func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
@@ -96,7 +92,6 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		ServerConf:        sc,
 		DBConf:            envConf.DBConf,
 		RedisConf:         envConf.RedisConf,
-		BillingManager:    InstanceBillingManager,
 		CredentialBackend: instanceCredentialBackend,
 	}
 	res.Logger.Info().Msg("Loading MetadataFromConf")
@@ -252,10 +247,6 @@ 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{
@@ -342,6 +333,29 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		CollectorURL: sc.TelemetryCollectorURL,
 	}
 
+	var (
+		stripeClient    billing.StripeClient
+		metronomeClient billing.MetronomeClient
+	)
+	if sc.StripeSecretKey != "" {
+		stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
+	} else {
+		res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled")
+	}
+
+	if sc.MetronomeAPIKey != "" {
+		metronomeClient = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey)
+	} else {
+		res.Logger.Info().Msg("METRONOME_API_KEY not set, all Metronome functionality will be disabled")
+	}
+
+	res.Logger.Info().Msg("Creating billing manager")
+	res.BillingManager = billing.Manager{
+		StripeClient:    stripeClient,
+		MetronomeClient: metronomeClient,
+	}
+	res.Logger.Info().Msg("Created billing manager")
+
 	return res, nil
 }
 

+ 93 - 0
api/types/billing_metronome.go

@@ -0,0 +1,93 @@
+package types
+
+import "github.com/google/uuid"
+
+// Customer represents a customer in Metronome
+type Customer struct {
+	ID   uuid.UUID `json:"id"`
+	Name string    `json:"name"`
+	// Aliases are alternative ids that can be used to refer to this customer in usage events
+	Aliases       []string          `json:"ingest_aliases"`
+	BillingConfig BillingConfig     `json:"billing_config,omitempty"`
+	CustomFields  map[string]string `json:"custom_fields,omitempty"`
+}
+
+// CustomerArchiveRequest will archive the customer in Metronome.
+type CustomerArchiveRequest struct {
+	CustomerID uuid.UUID `json:"id"`
+}
+
+// BillingConfig is the configuration for the billing provider (Stripe, etc.)
+type BillingConfig struct {
+	// BillingProviderType is the name of the billing provider (e.g. )
+	BillingProviderType       string `json:"billing_provider_type"`
+	BillingProviderCustomerID string `json:"billing_provider_customer_id"`
+	// StripeCollectionMethod defines if invoices are charged automatically or sent to customers
+	StripeCollectionMethod string `json:"stripe_collection_method"`
+}
+
+// AddCustomerPlanRequest represents a request to add a customer plan with specific details.
+type AddCustomerPlanRequest struct {
+	PlanID uuid.UUID `json:"plan_id"`
+	// StartingOn is a RFC3339 timestamp for when the plan becomes active for this customer. Must be at 0:00 UTC (midnight)
+	StartingOnUTC string `json:"starting_on"`
+	// EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight)
+	EndingBeforeUTC string `json:"ending_before,omitempty"`
+	// NetPaymentTermsDays is the number of days after issuance of invoice after which the invoice is due
+	NetPaymentTermsDays int `json:"net_payment_terms_days,omitempty"`
+}
+
+// EndCustomerPlanRequest represents a request to end the plan for a specific customer.
+type EndCustomerPlanRequest struct {
+	// EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight).
+	EndingBeforeUTC string `json:"ending_before,omitempty"`
+	// VoidInvoices determines if Metronome invoices are voided. If set to true, the plan end date can be before the last finalized invoice date.
+	// and any invoices generated after the plan end date will be voided.
+	VoidInvoices bool `json:"void_invoices"`
+	// VoidStripeInvoices determines if Stripe invoices are void (if VoidInvoices is set to true). Drafts will be deleted.
+	VoidStripeInvoices bool `json:"void_stripe_invoices"`
+}
+
+// ListCreditGrantsRequest is the request to list a user's credit grants. Note that only one of
+// CreditTypeIDs, CustomerIDs, or CreditGrantIDs must be specified.
+type ListCreditGrantsRequest struct {
+	CreditTypeIDs  []uuid.UUID `json:"credit_type_ids,omitempty"`
+	CustomerIDs    []uuid.UUID `json:"customer_ids,omitempty"`
+	CreditGrantIDs []uuid.UUID `json:"credit_grant_ids,omitempty"`
+	// NotExpiringBefore will return grants that expire at or after this RFC 3339 timestamp.
+	NotExpiringBefore string `json:"not_expiring_before,omitempty"`
+	// EffectiveBefore will return grants that are effective before this RFC 3339 timestamp (exclusive).
+	EffectiveBefore string `json:"effective_before,omitempty"`
+}
+
+// CreditType is the type of the credit used in the credit grant
+type CreditType struct {
+	Name string `json:"name"`
+	ID   string `json:"id"`
+}
+
+// GrantAmount represents the amount of credits granted
+type GrantAmount struct {
+	Amount     int64      `json:"amount"`
+	CreditType CreditType `json:"credit_type"`
+}
+
+// Balance represents the effective balance of the grant as of the end of the customer's
+// current billing period.
+type Balance struct {
+	// ExcludingPending is the grant's current balance excluding pending deductions
+	ExcludingPending int64 `json:"excluding_pending"`
+	// IncludingPending is the grant's current balance including pending deductions
+	IncludingPending int64 `json:"including_pending"`
+	// EffectiveAt is a RFC3339 timestamp that can be used to filter credit grants by effective date
+	EffectiveAt string `json:"effective_at"`
+}
+
+// CreditGrant is a grant given to a specific user on a specific plan
+type CreditGrant struct {
+	ID          uuid.UUID `json:"id"`
+	Name        string
+	CustomerID  uuid.UUID
+	GrantAmount GrantAmount
+	Balance     Balance
+}

+ 0 - 0
api/types/billing.go → api/types/billing_stripe.go


+ 1 - 0
api/types/project.go

@@ -39,6 +39,7 @@ type Project struct {
 	BetaFeaturesEnabled             bool    `json:"beta_features_enabled"`
 	CapiProvisionerEnabled          bool    `json:"capi_provisioner_enabled"`
 	BillingEnabled                  bool    `json:"billing_enabled"`
+	MetronomeEnabled                bool    `json:"metronome_enabled"`
 	DBEnabled                       bool    `json:"db_enabled"`
 	EFSEnabled                      bool    `json:"efs_enabled"`
 	EnableReprovision               bool    `json:"enable_reprovision"`

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

@@ -35,6 +35,10 @@ type TGetPublishableKey = {
   publishableKey: string;
 };
 
+type TGetCredits = {
+  credits: number;
+};
+
 export const usePaymentMethods = (): TUsePaymentMethod => {
   const { currentProject } = useContext(Context);
 
@@ -176,6 +180,32 @@ export const usePublishableKey = (): TGetPublishableKey => {
   };
 };
 
+export const usePorterCredits = (): TGetCredits => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch available credits
+  const creditsReq = useQuery(
+    ["getPorterCredits", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getPorterCredits(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      return res.data;
+    }
+  );
+
+  return {
+    credits: creditsReq.data,
+  };
+};
+
 export const useSetDefaultPaymentMethod = (): TSetDefaultPaymentMethod => {
   const { currentProject } = useContext(Context);
 

+ 29 - 15
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -12,6 +12,7 @@ import Text from "components/porter/Text";
 import {
   checkIfProjectHasPayment,
   usePaymentMethods,
+  usePorterCredits,
   useSetDefaultPaymentMethod,
 } from "lib/hooks/useStripe";
 
@@ -25,6 +26,9 @@ import BillingModal from "../modals/BillingModal";
 function BillingPage(): JSX.Element {
   const { setCurrentOverlay } = useContext(Context);
   const [shouldCreate, setShouldCreate] = useState(false);
+  const { currentProject } = useContext(Context);
+
+  const { credits } = usePorterCredits();
 
   const {
     paymentMethodList,
@@ -36,6 +40,10 @@ function BillingPage(): JSX.Element {
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
+  const formatCredits = (credits: number): string => {
+    return (credits / 100).toFixed(2);
+  };
+
   const onCreate = async () => {
     await refetchPaymentMethods();
     setShouldCreate(false);
@@ -55,21 +63,27 @@ function BillingPage(): JSX.Element {
 
   return (
     <>
-      <Text size={16}>Porter credit balance</Text>
-      <Spacer y={1} />
-      <Text color="helper">
-        View the amount of Porter credits you have available to spend on
-        resources within this project.
-      </Text>
-      <Spacer y={1} />
-      <Container row>
-        <Image src={gift} style={{ marginTop: "-2px" }} />
-        <Spacer inline x={1} />
-        <Text size={20}>
-          {paymentMethodList?.length > 0 ? "$ 5.00" : "$ 0.00"}
-        </Text>
-      </Container>
-      <Spacer y={2} />
+      {currentProject?.metronome_enabled ? (
+        <div>
+          <Text size={16}>Porter credit balance</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            View the amount of Porter credits you have available to spend on
+            resources within this project.
+          </Text>
+          <Spacer y={1} />
+          <Container row>
+            <Image src={gift} style={{ marginTop: "-2px" }} />
+            <Spacer inline x={1} />
+            <Text size={20}>
+              {credits > 0 ? `$${formatCredits(credits)}` : "$ 0.00"}
+            </Text>
+          </Container>
+          <Spacer y={2} />
+        </div>
+      ) : (
+        <div></div>
+      )}
       <Text size={16}>Payment methods</Text>
       <Spacer y={1} />
       <Text color="helper">

+ 8 - 0
dashboard/src/shared/api.tsx

@@ -3451,6 +3451,13 @@ const getPublishableKey = baseApi<
   ({ project_id }) => `/api/projects/${project_id}/billing/publishable_key`
 );
 
+const getPorterCredits = baseApi<
+  {},
+  {
+    project_id?: number;
+  }
+>("GET", ({ project_id }) => `/api/projects/${project_id}/billing/credits`);
+
 const getHasBilling = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/billing`
@@ -3848,6 +3855,7 @@ export default {
 
   // BILLING
   getPublishableKey,
+  getPorterCredits,
   listPaymentMethod,
   addPaymentMethod,
   setDefaultPaymentMethod,

+ 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;
@@ -316,6 +316,7 @@ export type ProjectType = {
   azure_enabled: boolean;
   beta_features_enabled: boolean;
   billing_enabled: boolean;
+  metronome_enabled: boolean;
   capi_provisioner_enabled: boolean;
   db_enabled: boolean;
   efs_enabled: boolean;
@@ -377,15 +378,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";

+ 4 - 75
internal/billing/billing.go

@@ -1,78 +1,7 @@
 package billing
 
-import (
-	"context"
-
-	"github.com/porter-dev/porter/api/types"
-)
-
-// BillingManager contains methods for managing billing for a project
-type BillingManager interface {
-	// 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(ctx context.Context, userEmail string, projectID uint, projectName string) (customerID string, err error)
-
-	// DeleteCustomer will delete the customer from the billing provider
-	DeleteCustomer(ctx context.Context, customerID string) (err error)
-
-	// CheckPaymentEnabled will check if the project has a payment method configured
-	CheckPaymentEnabled(ctx context.Context, customerID string) (paymentEnabled bool, err error)
-
-	// ListPaymentMethod will return all payment methods for the project
-	ListPaymentMethod(ctx context.Context, customerID string) (paymentMethods []types.PaymentMethod, err error)
-
-	// CreatePaymentMethod will add a new payment method to the project in Stripe
-	CreatePaymentMethod(ctx context.Context, customerID string) (clientSecret string, err error)
-
-	// SetDefaultPaymentMethod will set the payment method as default in the customer invoice settings
-	SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, customerID string) (err error)
-
-	// DeletePaymentMethod will remove a payment method for the project in Stripe
-	DeletePaymentMethod(ctx context.Context, paymentMethodID string) (err error)
-
-	// GetPublishableKey returns the key used to render frontend components for the billing manager
-	GetPublishableKey(ctx context.Context) (key string)
-}
-
-// NoopBillingManager performs no billing operations
-type NoopBillingManager struct{}
-
-// CreateCustomer is a no-op
-func (s *NoopBillingManager) CreateCustomer(ctx context.Context, userEmail string, projectID uint, projectName string) (customerID string, err error) {
-	return "", nil
-}
-
-// DeleteCustomer is a no-op
-func (s *NoopBillingManager) DeleteCustomer(ctx context.Context, customerID string) (err error) {
-	return nil
-}
-
-// CheckPaymentEnabled is a  no-op
-func (s *NoopBillingManager) CheckPaymentEnabled(ctx context.Context, customerID string) (paymentEnabled bool, err error) {
-	return false, nil
-}
-
-// ListPaymentMethod is a no-op
-func (s *NoopBillingManager) ListPaymentMethod(ctx context.Context, customerID string) (paymentMethods []types.PaymentMethod, err error) {
-	return []types.PaymentMethod{}, nil
-}
-
-// CreatePaymentMethod is a no-op
-func (s *NoopBillingManager) CreatePaymentMethod(ctx context.Context, customerID string) (clientSecret string, err error) {
-	return "", nil
-}
-
-// SetDefaultPaymentMethod is a no-op
-func (s *NoopBillingManager) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, customerID string) (err error) {
-	return nil
-}
-
-// DeletePaymentMethod is a no-op
-func (s *NoopBillingManager) DeletePaymentMethod(ctx context.Context, paymentMethodID string) (err error) {
-	return nil
-}
-
-// GetPublishableKey is a no-op
-func (s *NoopBillingManager) GetPublishableKey(ctx context.Context) (key string) {
-	return ""
+// Manager contains methods for managing billing for a project
+type Manager struct {
+	StripeClient    StripeClient
+	MetronomeClient MetronomeClient
 }

+ 205 - 0
internal/billing/metronome.go

@@ -0,0 +1,205 @@
+package billing
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/api/types"
+)
+
+const (
+	metronomeBaseUrl         = "https://api.metronome.com/v1/"
+	defaultCollectionMethod  = "charge_automatically"
+	defaultGrantCredits      = 5000
+	defaultGrantName         = "Starter Credits"
+	defaultGrantExpiryMonths = 1
+)
+
+// MetronomeClient is the client used to call the Metronome API
+type MetronomeClient struct {
+	ApiKey string
+}
+
+// NewMetronomeClient returns a new Metronome client
+func NewMetronomeClient(metronomeApiKey string) MetronomeClient {
+	return MetronomeClient{
+		ApiKey: metronomeApiKey,
+	}
+}
+
+// createCustomer will create the customer in Metronome
+func (m MetronomeClient) createCustomer(orgName string, projectName string, projectID uint, billingID string) (customerID uuid.UUID, err error) {
+	path := "customers"
+	projIDStr := strconv.FormatUint(uint64(projectID), 10)
+
+	customer := types.Customer{
+		Name: fmt.Sprintf("%s - %s", orgName, projectName),
+		Aliases: []string{
+			projIDStr,
+		},
+		BillingConfig: types.BillingConfig{
+			BillingProviderType:       "stripe",
+			BillingProviderCustomerID: billingID,
+			StripeCollectionMethod:    defaultCollectionMethod,
+		},
+	}
+
+	var result struct {
+		Data types.Customer `json:"data"`
+	}
+
+	err = post(path, m.ApiKey, customer, &result)
+	if err != nil {
+		return customerID, err
+	}
+	return result.Data.ID, nil
+}
+
+// addCustomerPlan will start the customer on the given plan
+func (m MetronomeClient) addCustomerPlan(customerID uuid.UUID, planID uuid.UUID) (customerPlanID uuid.UUID, err error) {
+	if customerID == uuid.Nil || planID == uuid.Nil {
+		return customerPlanID, fmt.Errorf("customer or plan id empty")
+	}
+
+	path := fmt.Sprintf("/customers/%s/plans/add", customerID)
+
+	// Plan start time must be midnight UTC, formatted as RFC3339 timestamp
+	now := time.Now()
+	midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+	startOn := midnightUTC.Format(time.RFC3339)
+
+	req := types.AddCustomerPlanRequest{
+		PlanID:        planID,
+		StartingOnUTC: startOn,
+	}
+
+	var result struct {
+		Data struct {
+			CustomerPlanID uuid.UUID `json:"id"`
+		} `json:"data"`
+	}
+
+	err = post(path, m.ApiKey, req, &result)
+	if err != nil {
+		return customerPlanID, err
+	}
+
+	return result.Data.CustomerPlanID, nil
+}
+
+// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
+func (m MetronomeClient) CreateCustomerWithPlan(orgName string, projectName string, projectID uint, billingID string, planID string) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) {
+	porterCloudPlanID, err := uuid.Parse(planID)
+	if err != nil {
+		return customerID, customerPlanID, fmt.Errorf("error parsing starter plan id: %w", err)
+	}
+
+	customerID, err = m.createCustomer(orgName, projectName, projectID, billingID)
+	if err != nil {
+		return customerID, customerPlanID, err
+	}
+
+	customerPlanID, err = m.addCustomerPlan(customerID, porterCloudPlanID)
+
+	return customerID, customerPlanID, err
+}
+
+// EndCustomerPlan will immediately end the plan for the given customer
+func (m MetronomeClient) EndCustomerPlan(customerID uuid.UUID, customerPlanID uuid.UUID) (err error) {
+	if customerID == uuid.Nil || customerPlanID == uuid.Nil {
+		return fmt.Errorf("customer or customer plan id empty")
+	}
+
+	path := fmt.Sprintf("/customers/%s/plans/%s/end", customerID, customerPlanID)
+
+	// Plan start time must be midnight UTC, formatted as RFC3339 timestamp
+	now := time.Now()
+	midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+	endBefore := midnightUTC.Format(time.RFC3339)
+
+	req := types.EndCustomerPlanRequest{
+		EndingBeforeUTC: endBefore,
+	}
+
+	err = post(path, m.ApiKey, req, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GetCustomerCredits will return the first credit grant for the customer
+func (m MetronomeClient) GetCustomerCredits(customerID uuid.UUID) (credits int64, err error) {
+	if customerID == uuid.Nil {
+		return credits, fmt.Errorf("customer id empty")
+	}
+
+	path := "credits/listGrants"
+
+	req := types.ListCreditGrantsRequest{
+		CustomerIDs: []uuid.UUID{
+			customerID,
+		},
+	}
+
+	var result struct {
+		Data []types.CreditGrant `json:"data"`
+	}
+
+	err = post(path, m.ApiKey, req, &result)
+	if err != nil {
+		return credits, err
+	}
+
+	return result.Data[0].Balance.IncludingPending, nil
+}
+
+func post(path string, apiKey string, body interface{}, data interface{}) (err error) {
+	client := http.Client{}
+	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
+	if err != nil {
+		return err
+	}
+
+	var bodyJson []byte
+	if body != nil {
+		bodyJson, err = json.Marshal(body)
+		if err != nil {
+			return err
+		}
+	}
+
+	req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(bodyJson))
+	if err != nil {
+		return err
+	}
+	bearer := "Bearer " + apiKey
+	req.Header.Set("Authorization", bearer)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("non 200 status code returned: %d", resp.StatusCode)
+	}
+
+	if data != nil {
+		err = json.NewDecoder(resp.Body).Decode(data)
+		if err != nil {
+			return err
+		}
+	}
+	_ = resp.Body.Close()
+
+	return nil
+}

+ 29 - 21
internal/billing/stripe.go

@@ -13,15 +13,23 @@ import (
 	"github.com/stripe/stripe-go/v76/setupintent"
 )
 
-// StripeBillingManager interacts with the Stripe API to manage payment methods
+// StripeClient interacts with the Stripe API to manage payment methods
 // and customers
-type StripeBillingManager struct {
-	StripeSecretKey      string
-	StripePublishableKey string
+type StripeClient struct {
+	SecretKey      string
+	PublishableKey string
+}
+
+// NewStripeClient creates a new client to call the Stripe API
+func NewStripeClient(secretKey string, publishableKey string) StripeClient {
+	return StripeClient{
+		SecretKey:      secretKey,
+		PublishableKey: publishableKey,
+	}
 }
 
 // CreateCustomer will create a customer in Stripe only if the project doesn't have a BillingID
-func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail string, projectID uint, projectName string) (customerID string, err error) {
+func (s StripeClient) CreateCustomer(ctx context.Context, userEmail string, projectID uint, projectName string) (customerID string, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-stripe-customer")
 	defer span.End()
 
@@ -29,7 +37,7 @@ func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail str
 		return "", fmt.Errorf("invalid project id or name")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	// Create customer if not exists
 	customerName := fmt.Sprintf("project_%s", projectName)
@@ -60,7 +68,7 @@ func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail str
 }
 
 // DeleteCustomer will delete the customer from the billing provider
-func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID string) (err error) {
+func (s StripeClient) DeleteCustomer(ctx context.Context, customerID string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "delete-stripe-customer")
 	defer span.End()
 
@@ -68,7 +76,7 @@ func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID st
 		return nil
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "billing-id", Value: customerID},
@@ -84,7 +92,7 @@ func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID st
 }
 
 // CheckPaymentEnabled will return true if the project has a payment method added, false otherwise
-func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customerID string) (paymentEnabled bool, err error) {
+func (s StripeClient) CheckPaymentEnabled(ctx context.Context, customerID string) (paymentEnabled bool, err error) {
 	_, span := telemetry.NewSpan(ctx, "check-stripe-payment-enabled")
 	defer span.End()
 
@@ -92,7 +100,7 @@ func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customer
 		return false, fmt.Errorf("customer id cannot be empty")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	params := &stripe.PaymentMethodListParams{
 		Customer: stripe.String(customerID),
@@ -104,7 +112,7 @@ func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customer
 }
 
 // ListPaymentMethod will return all payment methods for the project
-func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID string) (paymentMethods []types.PaymentMethod, err error) {
+func (s StripeClient) ListPaymentMethod(ctx context.Context, customerID string) (paymentMethods []types.PaymentMethod, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-stripe-payment-method")
 	defer span.End()
 
@@ -112,7 +120,7 @@ func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID
 		return paymentMethods, fmt.Errorf("customer id cannot be empty")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	// Get configured payment methods
 	params := &stripe.PaymentMethodListParams{
@@ -157,7 +165,7 @@ func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID
 }
 
 // CreatePaymentMethod will add a new payment method to the project in Stripe
-func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customerID string) (clientSecret string, err error) {
+func (s StripeClient) CreatePaymentMethod(ctx context.Context, customerID string) (clientSecret string, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-stripe-payment-method")
 	defer span.End()
 
@@ -165,7 +173,7 @@ func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customer
 		return "", fmt.Errorf("customer id cannot be empty")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	params := &stripe.SetupIntentParams{
 		Customer: stripe.String(customerID),
@@ -184,7 +192,7 @@ func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customer
 }
 
 // SetDefaultPaymentMethod will add a new payment method to the project in Stripe
-func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, customerID string) (err error) {
+func (s StripeClient) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, customerID string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "set-default-stripe-payment-method")
 	defer span.End()
 
@@ -192,7 +200,7 @@ func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paym
 		return fmt.Errorf("empty customer id or payment method id")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	params := &stripe.CustomerParams{
 		InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{
@@ -209,7 +217,7 @@ func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paym
 }
 
 // DeletePaymentMethod will remove a payment method for the project in Stripe
-func (s *StripeBillingManager) DeletePaymentMethod(ctx context.Context, paymentMethodID string) (err error) {
+func (s StripeClient) DeletePaymentMethod(ctx context.Context, paymentMethodID string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "delete-stripe-payment-method")
 	defer span.End()
 
@@ -217,7 +225,7 @@ func (s *StripeBillingManager) DeletePaymentMethod(ctx context.Context, paymentM
 		return fmt.Errorf("payment method id cannot be empty")
 	}
 
-	stripe.Key = s.StripeSecretKey
+	stripe.Key = s.SecretKey
 
 	_, err = paymentmethod.Detach(paymentMethodID, nil)
 	if err != nil {
@@ -228,14 +236,14 @@ func (s *StripeBillingManager) DeletePaymentMethod(ctx context.Context, paymentM
 }
 
 // GetPublishableKey returns the Stripe publishable key
-func (s *StripeBillingManager) GetPublishableKey(ctx context.Context) (key string) {
+func (s StripeClient) GetPublishableKey(ctx context.Context) (key string) {
 	_, span := telemetry.NewSpan(ctx, "get-stripe-publishable-key")
 	defer span.End()
 
-	return s.StripePublishableKey
+	return s.PublishableKey
 }
 
-func (s *StripeBillingManager) checkDefaultPaymentMethod(customerID string) (defaultPaymentExists bool, defaultPaymentID string, err error) {
+func (s StripeClient) checkDefaultPaymentMethod(customerID string) (defaultPaymentExists bool, defaultPaymentID string, err error) {
 	// Get customer to check default payment method
 	customer, err := customer.Get(customerID, nil)
 	if err != nil {

+ 11 - 2
internal/models/project.go

@@ -5,6 +5,7 @@ import (
 
 	"gorm.io/gorm"
 
+	"github.com/google/uuid"
 	"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/features"
@@ -28,6 +29,9 @@ const (
 	// BillingEnabled enables the "Billing" tab and all Stripe integrations
 	BillingEnabled FeatureFlagLabel = "billing_enabled"
 
+	// MetronomeEnabled enables all Metronome business logic
+	MetronomeEnabled FeatureFlagLabel = "metronome_enabled"
+
 	// DBEnabled enables the "Databases" tab
 	DBEnabled FeatureFlagLabel = "db_enabled"
 
@@ -97,6 +101,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	BetaFeaturesEnabled:             false,
 	CapiProvisionerEnabled:          true,
 	BillingEnabled:                  false,
+	MetronomeEnabled:                false,
 	DBEnabled:                       false,
 	EFSEnabled:                      false,
 	EnableReprovision:               false,
@@ -137,8 +142,11 @@ type Project struct {
 	BillingID      string
 	BillingEnabled bool
 
-	ProjectUsageID      uint
-	ProjectUsageCacheID uint
+	// UsageID is the id corresponding to the customer in Metronome
+	UsageID uuid.UUID
+	// UsagePlanID is the id of the customer-plan relationship. Do not confuse with the actual plan ID.
+	// This exists as long as a user is part of a plan.
+	UsagePlanID uuid.UUID
 
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
@@ -299,6 +307,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		BetaFeaturesEnabled:             p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
 		CapiProvisionerEnabled:          p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient),
 		BillingEnabled:                  p.GetFeatureFlag(BillingEnabled, launchDarklyClient),
+		MetronomeEnabled:                p.GetFeatureFlag(MetronomeEnabled, launchDarklyClient),
 		DBEnabled:                       p.GetFeatureFlag(DBEnabled, launchDarklyClient),
 		EFSEnabled:                      p.GetFeatureFlag(EFSEnabled, launchDarklyClient),
 		EnableReprovision:               p.GetFeatureFlag(EnableReprovision, launchDarklyClient),

+ 7 - 1
zarf/helm/.serverenv

@@ -67,8 +67,14 @@ 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 is used to create customers and payment method in Stripe. If empty all functionality will be disabled.
 STRIPE_SECRET_KEY=
 
 # STRIPE_PUBLISHABLE_KEY is used in the frontend to create Stripe Web Elements
 STRIPE_PUBLISHABLE_KEY=
+
+# METRONOME_API_KEY is used to create customers in Metronome. If empty all functionality will be disabled.
+METRONOME_API_KEY=
+
+# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Metronome. Only used if METRONOME_API_KEY is set
+PORTER_CLOUD_PLAN_ID=