From 8bb2d3e70baf7b076e20b670d96c731cbe7243b1 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 2 Apr 2024 18:57:13 -0400 Subject: [PATCH] Add Metronome business logic --- api/server/handlers/billing/create.go | 4 +- api/server/handlers/billing/customer.go | 4 +- api/server/handlers/billing/delete.go | 2 +- api/server/handlers/billing/list.go | 4 +- api/server/handlers/project/create.go | 70 +++++++-- api/server/handlers/project/delete.go | 28 +++- api/server/shared/config/env/envconfs.go | 15 +- api/server/shared/config/loader/loader.go | 12 +- api/types/billing.go | 51 +++++++ internal/billing/billing.go | 78 +--------- internal/billing/metronome.go | 171 ++++++++++++++++++++++ internal/billing/stripe.go | 49 ++++--- internal/models/project.go | 8 +- zarf/helm/.serverenv | 8 +- 14 files changed, 373 insertions(+), 131 deletions(-) create mode 100644 internal/billing/metronome.go diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 9f7f7542506..fe3422e170c 100644 --- a/api/server/handlers/billing/create.go +++ b/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) + clientSecret, err := c.Config().BillingManager.StripeClient.CreatePaymentMethod(ctx, 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))) @@ -81,7 +81,7 @@ func (c *SetDefaultBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - err := c.Config().BillingManager.SetDefaultPaymentMethod(ctx, paymentMethodID, proj) + 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))) diff --git a/api/server/handlers/billing/customer.go b/api/server/handlers/billing/customer.go index 8acd7702dcd..4418377dc85 100644 --- a/api/server/handlers/billing/customer.go +++ b/api/server/handlers/billing/customer.go @@ -42,7 +42,7 @@ func (c *CreateBillingCustomerHandler) ServeHTTP(w http.ResponseWriter, r *http. } // Create customer in Stripe - customerID, err := c.Config().BillingManager.CreateCustomer(ctx, user.Email, proj) + 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))) @@ -92,7 +92,7 @@ func (c *GetPublishableKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ // 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}, diff --git a/api/server/handlers/billing/delete.go b/api/server/handlers/billing/delete.go index 2a3c4c03683..2275b61bd4d 100644 --- a/api/server/handlers/billing/delete.go +++ b/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))) diff --git a/api/server/handlers/billing/list.go b/api/server/handlers/billing/list.go index a8ed0a7214c..2ec2d6345ca 100644 --- a/api/server/handlers/billing/list.go +++ b/api/server/handlers/billing/list.go @@ -39,7 +39,7 @@ func (c *ListBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - paymentMethods, err := c.Config().BillingManager.ListPaymentMethod(ctx, proj) + 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))) @@ -65,7 +65,7 @@ func (c *CheckPaymentEnabledHandler) ServeHTTP(w http.ResponseWriter, r *http.Re proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - paymentEnabled, err := c.Config().BillingManager.CheckPaymentEnabled(ctx, proj) + paymentEnabled, err := c.Config().BillingManager.StripeClient.CheckPaymentEnabled(ctx, 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))) diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index 0d21777cbf1..1d587b71627 100644 --- a/api/server/handlers/project/create.go +++ b/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" @@ -55,9 +56,34 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) var err error + proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user) + if err != nil { + err = telemetry.Error(ctx, span, err, "error creating project with user") + p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + step := types.StepConnectSource + + if p.Config().ServerConf.EnableSandbox { + step = types.StepCleanUp + } + + // create onboarding flow set to the first step. Read in env var + _, err = p.Repo().Onboarding().CreateProjectOnboarding(&models.Onboarding{ + ProjectID: proj.ID, + CurrentStep: step, + }) + if err != nil { + err = telemetry.Error(ctx, span, err, "error creating project onboarding") + p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + 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) + 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)) @@ -72,26 +98,38 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) ) } - proj, _, err = CreateProjectWithUser(p.Repo().Project(), proj, user) - if err != nil { - err = telemetry.Error(ctx, span, err, "error creating project with user") - p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return + // 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 } - step := types.StepConnectSource - - if p.Config().ServerConf.EnableSandbox { - step = types.StepCleanUp + // 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 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 } - // create onboarding flow set to the first step. Read in env var - _, err = p.Repo().Onboarding().CreateProjectOnboarding(&models.Onboarding{ - ProjectID: proj.ID, - CurrentStep: step, - }) + _, err = p.Repo().Project().UpdateProject(proj) if err != nil { - err = telemetry.Error(ctx, span, err, "error creating project onboarding") + err := telemetry.Error(ctx, span, err, "error updating project") p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) return } diff --git a/api/server/handlers/project/delete.go b/api/server/handlers/project/delete.go index 1ddc73ce45e..8b012c522fa 100644 --- a/api/server/handlers/project/delete.go +++ b/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) + 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) diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index ed386eac4ce..374480e9a57 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/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"` diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index 1b45caa9a31..3597988ed28 100644 --- a/api/server/shared/config/loader/loader.go +++ b/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{ diff --git a/api/types/billing.go b/api/types/billing.go index dbb6b47af48..bb4bc2123da 100644 --- a/api/types/billing.go +++ b/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. +} diff --git a/internal/billing/billing.go b/internal/billing/billing.go index 0d5a776cfa8..c1b8a912dfb 100644 --- a/internal/billing/billing.go +++ b/internal/billing/billing.go @@ -1,79 +1,7 @@ package billing -import ( - "context" - - "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 { - // 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, proj *models.Project) (customerID string, err error) - - // DeleteCustomer will delete the customer from the billing provider - DeleteCustomer(ctx context.Context, proj *models.Project) (err error) - - // CheckPaymentEnabled will check if the project has a payment method configured - CheckPaymentEnabled(ctx context.Context, proj *models.Project) (paymentEnabled bool, err error) - - // ListPaymentMethod will return all payment methods for the project - ListPaymentMethod(ctx context.Context, proj *models.Project) (paymentMethods []types.PaymentMethod, err error) - - // CreatePaymentMethod will add a new payment method to the project in Stripe - CreatePaymentMethod(ctx context.Context, proj *models.Project) (clientSecret string, err error) - - // SetDefaultPaymentMethod will set the payment method as default in the customer invoice settings - SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, proj *models.Project) (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, proj *models.Project) (customerID string, err error) { - return "", nil -} - -// DeleteCustomer is a no-op -func (s *NoopBillingManager) DeleteCustomer(ctx context.Context, proj *models.Project) (err error) { - return nil -} - -// CheckPaymentEnabled is a no-op -func (s *NoopBillingManager) CheckPaymentEnabled(ctx context.Context, proj *models.Project) (paymentEnabled bool, err error) { - return false, nil -} - -// ListPaymentMethod is a no-op -func (s *NoopBillingManager) ListPaymentMethod(ctx context.Context, proj *models.Project) (paymentMethods []types.PaymentMethod, err error) { - return []types.PaymentMethod{}, nil -} - -// CreatePaymentMethod is a no-op -func (s *NoopBillingManager) CreatePaymentMethod(ctx context.Context, proj *models.Project) (clientSecret string, err error) { - return "", nil -} - -// SetDefaultPaymentMethod is a no-op -func (s *NoopBillingManager) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, proj *models.Project) (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 "" +type BillingManager struct { + StripeClient *StripeClient + MetronomeClient *MetronomeClient } diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go new file mode 100644 index 00000000000..2753befc159 --- /dev/null +++ b/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 +} diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go index b667a3fcef5..d28dcf92d6f 100644 --- a/internal/billing/stripe.go +++ b/internal/billing/stripe.go @@ -14,19 +14,26 @@ 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 -func (s *StripeBillingManager) CreateCustomer(ctx context.Context, userEmail string, proj *models.Project) (customerID string, err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID}, @@ -57,11 +64,11 @@ 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, proj *models.Project) (err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID}, @@ -79,11 +86,11 @@ func (s *StripeBillingManager) DeleteCustomer(ctx context.Context, proj *models. } // CheckPaymentEnabled will return true if the project has a payment method added, false otherwise -func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, proj *models.Project) (paymentEnabled bool, err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey params := &stripe.PaymentMethodListParams{ Customer: stripe.String(proj.BillingID), @@ -95,11 +102,11 @@ func (s *StripeBillingManager) CheckPaymentEnabled(ctx context.Context, proj *mo } // ListPaymentMethod will return all payment methods for the project -func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, proj *models.Project) (paymentMethods []types.PaymentMethod, err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey // Get configured payment methods params := &stripe.PaymentMethodListParams{ @@ -144,11 +151,11 @@ func (s *StripeBillingManager) ListPaymentMethod(ctx context.Context, proj *mode } // CreatePaymentMethod will add a new payment method to the project in Stripe -func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, proj *models.Project) (clientSecret string, err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey params := &stripe.SetupIntentParams{ Customer: stripe.String(proj.BillingID), @@ -167,11 +174,11 @@ func (s *StripeBillingManager) CreatePaymentMethod(ctx context.Context, proj *mo } // SetDefaultPaymentMethod will add a new payment method to the project in Stripe -func (s *StripeBillingManager) SetDefaultPaymentMethod(ctx context.Context, paymentMethodID string, proj *models.Project) (err error) { +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.StripeSecretKey + stripe.Key = s.SecretKey params := &stripe.CustomerParams{ InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{ @@ -188,11 +195,11 @@ 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() - stripe.Key = s.StripeSecretKey + stripe.Key = s.SecretKey _, err = paymentmethod.Detach(paymentMethodID, nil) if err != nil { @@ -203,14 +210,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 { diff --git a/internal/models/project.go b/internal/models/project.go index dc52776d57c..b9e74cc1dca 100644 --- a/internal/models/project.go +++ b/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"` diff --git a/zarf/helm/.serverenv b/zarf/helm/.serverenv index e8ef0ccb53b..41957734203 100644 --- a/zarf/helm/.serverenv +++ b/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= \ No newline at end of file