Переглянути джерело

Add Metronome business logic

Mauricio Araujo 2 роки тому
батько
коміт
c3688a3380

+ 5 - 1
api/server/handlers/billing/create.go

@@ -42,7 +42,11 @@ func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
+<<<<<<< HEAD
 	clientSecret, err := c.Config().BillingManager.CreatePaymentMethod(ctx, proj.BillingID)
+=======
+	clientSecret, err := c.Config().BillingManager.StripeClient.CreatePaymentMethod(ctx, proj)
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 	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 +85,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)
 	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)))

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

@@ -0,0 +1,103 @@
+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,
+	writer shared.ResultWriter,
+) *CreateBillingCustomerHandler {
+	return &CreateBillingCustomerHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, 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)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	if proj.BillingID != "" {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	// Create customer in Stripe
+	customerID, err := c.Config().BillingManager.StripeClient.CreateCustomer(ctx, user.Email, 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
+	}
+
+	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},
+	)
+
+	// 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, "")
+}
+
+// GetPublishableKeyHandler will return the configured publishable key
+type GetPublishableKeyHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewGetPublishableKeyHandler will return the publishable key
+func NewGetPublishableKeyHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetPublishableKeyHandler {
+	return &GetPublishableKeyHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetPublishableKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "get-publishable-key-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	// 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.StripeClient.GetPublishableKey(ctx)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
+		telemetry.AttributeKV{Key: "customer-id", Value: proj.BillingID},
+	)
+
+	c.WriteResult(w, r, publishableKey)
+}

+ 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)))

+ 5 - 1
api/server/handlers/billing/list.go

@@ -58,7 +58,7 @@ func (c *ListBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	paymentMethods, err := c.Config().BillingManager.ListPaymentMethod(ctx, proj.BillingID)
+	paymentMethods, err := c.Config().BillingManager.StripeClient.ListPaymentMethod(ctx, 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)))
@@ -84,7 +84,11 @@ func (c *CheckPaymentEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
+<<<<<<< HEAD
 	paymentEnabled, err := c.Config().BillingManager.CheckPaymentEnabled(ctx, proj.BillingID)
+=======
+	paymentEnabled, err := c.Config().BillingManager.StripeClient.CheckPaymentEnabled(ctx, proj)
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 	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)))

+ 40 - 3
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,9 +80,10 @@ 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)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error creating billing customer")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -89,12 +91,47 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 		proj.BillingID = billingID
 
-		_, err = p.Repo().Project().UpdateProject(proj)
+		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
+	if p.Config().ServerConf.MetronomeAPIKey != "" {
+		usageID, err := p.Config().BillingManager.MetronomeClient.CreateCustomer(user.CompanyName, proj.Name, proj.ID, proj.BillingID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error creating billing customer")
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+		proj.UsageID = usageID
+	}
+
+	// Add customer to starter plan
+	if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" &&
+		p.Config().ServerConf.EnableSandbox {
+		porterCloudPlanID, err := uuid.Parse(p.Config().ServerConf.PorterCloudPlanID)
 		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error updating project")
+			err = telemetry.Error(ctx, span, err, "error parsing starter plan id")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
+		customerPlanID, err := p.Config().BillingManager.MetronomeClient.AddCustomerPlan(proj.UsageID, porterCloudPlanID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error adding customer to starter plan")
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+		proj.UsagePlanID = customerPlanID
+	}
+
+	_, err = p.Repo().Project().UpdateProject(proj)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error updating project")
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 
 	// create default project usage restriction

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

@@ -4,6 +4,7 @@ import (
 	"net/http"
 
 	"connectrpc.com/connect"
+	"github.com/google/uuid"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -92,7 +93,32 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = p.Config().BillingManager.DeleteCustomer(ctx, proj.BillingID)
+	if p.Config().ServerConf.MetronomeAPIKey != "" && p.Config().ServerConf.PorterCloudPlanID != "" {
+		porterCloudPlanID, err := uuid.Parse(p.Config().ServerConf.PorterCloudPlanID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error parsing starter plan id")
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		err = p.Config().BillingManager.MetronomeClient.EndCustomerPlan(proj.UsageID, porterCloudPlanID)
+		if err != nil {
+			e := "error deleting project in usage provider"
+			err = telemetry.Error(ctx, span, err, e)
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		err = p.Config().BillingManager.MetronomeClient.DeleteCustomer(proj.UsageID)
+		if err != nil {
+			e := "error deleting project in usage provider"
+			err = telemetry.Error(ctx, span, err, e)
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	}
+
+	err = p.Config().BillingManager.StripeClient.DeleteCustomer(ctx, proj)
 	if err != nil {
 		e := "error deleting project in billing provider"
 		err = telemetry.Error(ctx, span, err, e)

+ 10 - 5
api/server/shared/config/env/envconfs.go

@@ -1,6 +1,8 @@
 package env
 
-import "time"
+import (
+	"time"
+)
 
 // ServerConf is the server configuration
 type ServerConf struct {
@@ -69,10 +71,13 @@ type ServerConf struct {
 	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
-	StripeSecretKey      string `env:"STRIPE_SECRET_KEY"`
-	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
-	SlackClientID        string `env:"SLACK_CLIENT_ID"`
-	SlackClientSecret    string `env:"SLACK_CLIENT_SECRET"`
+	StripeSecretKey      string    `env:"STRIPE_SECRET_KEY"`
+	StripePublishableKey string    `env:"STRIPE_PUBLISHABLE_KEY"`
+	MetronomeAPIKey      string    `env:"METRONOME_API_KEY"`
+	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
+
+	SlackClientID     string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 
 	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
 	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`

+ 9 - 3
api/server/shared/config/loader/loader.go

@@ -62,9 +62,11 @@ func sharedInit() {
 		panic(err)
 	}
 
-	InstanceBillingManager = &billing.StripeBillingManager{
-		StripeSecretKey:      InstanceEnvConf.ServerConf.StripeSecretKey,
-		StripePublishableKey: InstanceEnvConf.ServerConf.StripePublishableKey,
+	stripeClient := billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
+	metronomeClient := billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey)
+	InstanceBillingManager = billing.BillingManager{
+		StripeClient:    stripeClient,
+		MetronomeClient: metronomeClient,
 	}
 }
 
@@ -256,6 +258,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled")
 	}
 
+	if sc.MetronomeAPIKey == "" {
+		res.Logger.Info().Msg("METRONOME_API_KEY not set, all Metronome functionality will be disabled")
+	}
+
 	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {
 		res.Logger.Info().Msg("Creating Slack client")
 		res.SlackConf = oauth.NewSlackClient(&oauth.Config{

+ 51 - 0
api/types/billing.go

@@ -1,5 +1,11 @@
 package types
 
+import (
+	"github.com/google/uuid"
+)
+
+// Stripe types
+
 // PaymentMethod is a subset of the Stripe PaymentMethod type,
 // with only the fields used in the dashboard
 type PaymentMethod = struct {
@@ -10,3 +16,48 @@ type PaymentMethod = struct {
 	ExpYear      int64  `json:"exp_year"`
 	Default      bool   `json:"is_default"`
 }
+
+// Metronome Types
+
+// Customer represents a customer in Metronome
+type Customer struct {
+	ID            uuid.UUID         `json:"id"`
+	Name          string            `json:"name"`           // Required. Name of the customer
+	Aliases       []string          `json:"ingest_aliases"` // Aliases that can be used to refer to this customer in usage events
+	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       string `json:"billing_provider_type"` // Required. Can be any of "aws_marketplace", "stripe", "netsuite", "custom", "azure_marketplace", "quickbooks_online", or "workday"
+	BillingProviderCustomerID string `json:"billing_provider_customer_id"`
+	StripeCollectionMethod    string `json:"stripe_collection_method"` // Can be any of "charge_automatically" or "send_invoice"
+}
+
+// AddCustomerPlanRequest represents a request to add a customer plan with specific details.
+type AddCustomerPlanRequest struct {
+	PlanID              uuid.UUID `json:"plan_id"`                          // Required. The customer ID, plan ID, and date range for the plan to be applied.
+	StartingOn          string    `json:"starting_on"`                      // Required. RFC 3339 timestamp for when the plan becomes active for this customer. Must be at 0:00 UTC (midnight).
+	EndingBefore        string    `json:"ending_before,omitempty"`          // Optional. RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight).
+	NetPaymentTermsDays int       `json:"net_payment_terms_days,omitempty"` // Number of days after issuance of invoice after which the invoice is due (e.g., Net 30).
+}
+
+// AddCustomerPlanResponse is a response to the add customer plan request. Returns customer-plan relationship id.
+type AddCustomerPlanResponse struct {
+	Data struct {
+		CustomerPlanID uuid.UUID `json:"id"`
+	} `json:"data"`
+}
+
+// EndCustomerPlanRequest represents a request to end the plan for a specific customer.
+type EndCustomerPlanRequest struct {
+	EndingBefore       string `json:"ending_before,omitempty"` // RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight).
+	VoidInvoices       bool   `json:"void_invoices"`           // If true, plan end date can be before the last finalized invoice date. Any invoices generated after the plan end date will be voided.
+	VoidStripeInvoices bool   `json:"void_stripe_invoices"`    // Will void Stripe invoices if VoidInvoices is set to true. Drafts will be deleted.
+}

+ 7 - 0
internal/billing/billing.go

@@ -1,5 +1,6 @@
 package billing
 
+<<<<<<< HEAD
 import (
 	"context"
 
@@ -75,4 +76,10 @@ func (s *NoopBillingManager) DeletePaymentMethod(ctx context.Context, paymentMet
 // GetPublishableKey is a no-op
 func (s *NoopBillingManager) GetPublishableKey(ctx context.Context) (key string) {
 	return ""
+=======
+// BillingManager contains methods for managing billing for a project
+type BillingManager struct {
+	StripeClient    *StripeClient
+	MetronomeClient *MetronomeClient
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 }

+ 171 - 0
internal/billing/metronome.go

@@ -0,0 +1,171 @@
+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
+)
+
+type MetronomeClient struct {
+	ApiKey string
+}
+
+func NewMetronomeClient(metronomeApiKey string) *MetronomeClient {
+	return &MetronomeClient{
+		ApiKey: metronomeApiKey,
+	}
+}
+
+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, http.MethodPost, m.ApiKey, customer, &result)
+	if err != nil {
+		return customerID, err
+	}
+	return result.Data.ID, nil
+}
+
+func (m *MetronomeClient) DeleteCustomer(customerID uuid.UUID) (err error) {
+	if customerID == uuid.Nil {
+		return fmt.Errorf("customer id cannot be empty")
+	}
+	path := "/customers/archive"
+
+	req := types.CustomerArchiveRequest{
+		CustomerID: customerID,
+	}
+
+	err = post(path, http.MethodPost, m.ApiKey, req, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+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,
+		StartingOn: startOn,
+	}
+
+	var result types.AddCustomerPlanResponse
+
+	err = post(path, http.MethodPost, m.ApiKey, req, result)
+	if err != nil {
+		return customerPlanID, err
+	}
+
+	return result.Data.CustomerPlanID, nil
+}
+
+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{
+		EndingBefore: endBefore,
+	}
+
+	err = post(path, http.MethodPost, m.ApiKey, req, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func post(path string, method 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(method, 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
+	}
+	defer resp.Body.Close()
+
+	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
+		}
+	}
+
+	return nil
+}

+ 67 - 8
internal/billing/stripe.go

@@ -13,14 +13,22 @@ 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
+}
+
+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
+<<<<<<< HEAD
 func (s *StripeBillingManager) 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()
@@ -30,6 +38,13 @@ func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail str
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) CreateCustomer(ctx context.Context, userEmail string, proj *models.Project) (customerID string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-stripe-customer")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	// Create customer if not exists
 	customerName := fmt.Sprintf("project_%s", projectName)
@@ -60,6 +75,7 @@ func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail str
 }
 
 // DeleteCustomer will delete the customer from the billing provider
+<<<<<<< HEAD
 func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "delete-stripe-customer")
 	defer span.End()
@@ -69,6 +85,13 @@ func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID st
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) DeleteCustomer(ctx context.Context, proj *models.Project) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "delete-stripe-customer")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "billing-id", Value: customerID},
@@ -84,6 +107,7 @@ func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, customerID st
 }
 
 // CheckPaymentEnabled will return true if the project has a payment method added, false otherwise
+<<<<<<< HEAD
 func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customerID string) (paymentEnabled bool, err error) {
 	_, span := telemetry.NewSpan(ctx, "check-stripe-payment-enabled")
 	defer span.End()
@@ -93,6 +117,13 @@ func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customer
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) CheckPaymentEnabled(ctx context.Context, proj *models.Project) (paymentEnabled bool, err error) {
+	_, span := telemetry.NewSpan(ctx, "check-stripe-payment-enabled")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	params := &stripe.PaymentMethodListParams{
 		Customer: stripe.String(customerID),
@@ -104,6 +135,7 @@ func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, customer
 }
 
 // ListPaymentMethod will return all payment methods for the project
+<<<<<<< HEAD
 func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID string) (paymentMethods []types.PaymentMethod, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "list-stripe-payment-method")
 	defer span.End()
@@ -113,6 +145,13 @@ func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) ListPaymentMethod(ctx context.Context, proj *models.Project) (paymentMethods []types.PaymentMethod, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-stripe-payment-method")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	// Get configured payment methods
 	params := &stripe.PaymentMethodListParams{
@@ -157,6 +196,7 @@ func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, customerID
 }
 
 // CreatePaymentMethod will add a new payment method to the project in Stripe
+<<<<<<< HEAD
 func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customerID string) (clientSecret string, err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-stripe-payment-method")
 	defer span.End()
@@ -166,6 +206,13 @@ func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customer
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) CreatePaymentMethod(ctx context.Context, proj *models.Project) (clientSecret string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-stripe-payment-method")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	params := &stripe.SetupIntentParams{
 		Customer: stripe.String(customerID),
@@ -184,6 +231,7 @@ func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, customer
 }
 
 // SetDefaultPaymentMethod will add a new payment method to the project in Stripe
+<<<<<<< HEAD
 func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, customerID string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "set-default-stripe-payment-method")
 	defer span.End()
@@ -193,6 +241,13 @@ func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paym
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+func (s *StripeClient) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, proj *models.Project) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "set-default-stripe-payment-method")
+	defer span.End()
+
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	params := &stripe.CustomerParams{
 		InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{
@@ -209,15 +264,19 @@ 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()
 
+<<<<<<< HEAD
 	if paymentMethodID == "" {
 		return fmt.Errorf("payment method id cannot be empty")
 	}
 
 	stripe.Key = s.StripeSecretKey
+=======
+	stripe.Key = s.SecretKey
+>>>>>>> b8c4273a5 (Add Metronome business logic)
 
 	_, err = paymentmethod.Detach(paymentMethodID, nil)
 	if err != nil {
@@ -228,14 +287,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 {

+ 6 - 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"
@@ -137,8 +138,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"`

+ 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=