Kaynağa Gözat

Lago integration (#4620)

Co-authored-by: jusrhee <justin@porter.run>
Mauricio Araujo 2 yıl önce
ebeveyn
işleme
a29f0b1ebc

+ 6 - 7
api/server/handlers/billing/create.go

@@ -136,7 +136,7 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr
 		return telemetry.Error(ctx, span, err, "failed to get referral count by referrer id")
 	}
 
-	maxReferralRewards := c.Config().BillingManager.MetronomeClient.MaxReferralRewards
+	maxReferralRewards := c.Config().BillingManager.LagoClient.MaxReferralRewards
 	if referralCount >= maxReferralRewards {
 		return nil
 	}
@@ -147,13 +147,12 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr
 	}
 
 	if referral != nil && referral.Status != models.ReferralStatusCompleted {
-		// Metronome requires an expiration to be passed in, so we set it to 5 years which in
+		// Lago requires an expiration to be passed in, so we set it to 5 years which in
 		// practice will mean the credits will most likely run out before expiring
-		expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339)
-		reason := "Referral reward"
-		rewardAmount := c.Config().BillingManager.MetronomeClient.DefaultRewardAmountCents
-		paidAmount := c.Config().BillingManager.MetronomeClient.DefaultPaidAmountCents
-		err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, rewardAmount, paidAmount, expiresAt)
+		expiresAt := time.Now().AddDate(5, 0, 0)
+		name := "Referral reward"
+		rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents
+		err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, &expiresAt, referrerProject.EnableSandbox)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "failed to grand credits reward")
 		}

+ 20 - 14
api/server/handlers/billing/ingest.go

@@ -39,22 +39,17 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-			telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
 	ingestEventsRequest := struct {
 		Events []types.BillingEvent `json:"billing_events"`
 	}{}
@@ -69,14 +64,25 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		telemetry.AttributeKV{Key: "usage-events-count", Value: len(ingestEventsRequest.Events)},
 	)
 
-	// For Porter Cloud events, we apend a prefix to avoid collisions before sending to Metronome
+	// For Porter Cloud events, we apend a prefix to avoid collisions before sending to Lago
 	if proj.EnableSandbox {
 		for i := range ingestEventsRequest.Events {
 			ingestEventsRequest.Events[i].CustomerID = fmt.Sprintf("porter-cloud-%s", ingestEventsRequest.Events[i].CustomerID)
 		}
 	}
 
-	err := c.Config().BillingManager.MetronomeClient.IngestEvents(ctx, ingestEventsRequest.Events)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
+	)
+
+	err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, plan.ID, ingestEventsRequest.Events, proj.EnableSandbox)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error ingesting events")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 7 - 17
api/server/handlers/billing/invoices.go

@@ -1,7 +1,6 @@
 package billing
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -25,7 +24,7 @@ func NewListCustomerInvoicesHandler(
 	writer shared.ResultWriter,
 ) *ListCustomerInvoicesHandler {
 	return &ListCustomerInvoicesHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}
 }
 
@@ -36,31 +35,22 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "billing-config-exists", Value: c.Config().BillingManager.StripeConfigLoaded},
-		telemetry.AttributeKV{Key: "billing-enabled", Value: proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 		telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
 	)
 
-	if !c.Config().BillingManager.StripeConfigLoaded || !proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 		return
 	}
 
-	req := &types.ListCustomerInvoicesRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, req); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding list customer usage request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	invoices, err := c.Config().BillingManager.StripeClient.ListCustomerInvoices(ctx, proj.BillingID, req.Status)
+	invoices, err := c.Config().BillingManager.LagoClient.ListCustomerFinalizedInvoices(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, fmt.Sprintf("error listing invoices for customer %s", proj.BillingID))
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		err = telemetry.Error(ctx, span, err, "error listing invoices")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// Write the response to the frontend
 	c.WriteResult(w, r, invoices)
 }

+ 23 - 16
api/server/handlers/billing/list.go

@@ -5,7 +5,6 @@ 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"
@@ -100,10 +99,9 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
 	)
 
-	if proj.BillingID == "" || proj.UsageID == uuid.Nil {
+	if proj.BillingID == "" {
 		adminUser, err := c.getAdminUser(ctx, proj.ID)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "error getting admin user")
@@ -119,11 +117,19 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "error ensuring Stripe customer exists")
 		}
+	}
+
+	lagoCustomerExists := false
+	if !lagoCustomerExists {
+		adminUser, err := c.getAdminUser(ctx, proj.ID)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "error getting admin user")
+		}
 
 		// Create usage customer for project and set the usage ID if it doesn't exist
-		err = c.ensureMetronomeCustomerExists(ctx, adminUser.Email, proj)
+		err = c.ensureLagoCustomerExists(ctx, adminUser.Email, proj)
 		if err != nil {
-			return telemetry.Error(ctx, span, err, "error ensuring Metronome customer exists")
+			return telemetry.Error(ctx, span, err, "error ensuring Lago customer exists")
 		}
 	}
 
@@ -189,30 +195,31 @@ func (c *CheckPaymentEnabledHandler) ensureStripeCustomerExists(ctx context.Cont
 	return nil
 }
 
-func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists")
+func (c *CheckPaymentEnabledHandler) ensureLagoCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ensure-lago-customer-exists")
 	defer span.End()
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID != uuid.Nil {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		return nil
 	}
 
-	customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
+	// Check if the customer already exists
+	exists, err := c.Config().BillingManager.LagoClient.CheckIfCustomerExists(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "error creating Metronome customer")
+		return telemetry.Error(ctx, span, err, "error while checking if customer exists")
 	}
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-		telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
+		telemetry.AttributeKV{Key: "customer-exists", Value: exists},
 	)
 
-	proj.UsageID = customerID
-	proj.UsagePlanID = customerPlanID
+	if exists {
+		return nil
+	}
 
-	_, err = c.Repo().Project().UpdateProject(proj)
+	err = c.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "error updating project")
+		return telemetry.Error(ctx, span, err, "error creating Lago customer")
 	}
 
 	return nil

+ 27 - 73
api/server/handlers/billing/plan.go

@@ -33,28 +33,27 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
-	plan, err := c.Config().BillingManager.MetronomeClient.ListCustomerPlan(ctx, proj.UsageID)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing plans")
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
+	)
+
 	c.WriteResult(w, r, plan)
 }
 
@@ -79,28 +78,23 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
-	credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID)
+	credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error listing credits")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
 	c.WriteResult(w, r, credits)
 }
 
@@ -127,12 +121,11 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 	)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 		return
 	}
@@ -145,59 +138,20 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing customer usage")
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
-	c.WriteResult(w, r, usage)
-}
-
-// ListCustomerCostsHandler returns customer usage aggregations like CPU and RAM hours.
-type ListCustomerCostsHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-// NewListCustomerCostsHandler returns a new ListCustomerCostsHandler
-func NewListCustomerCostsHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ListCustomerCostsHandler {
-	return &ListCustomerCostsHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-costs")
-	defer span.End()
-
-	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
 	)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
-		return
-	}
-
-	req := &types.ListCustomerCostsRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, req); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding list customer costs request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit)
+	usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod, req.PreviousPeriods)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing customer costs")
+		err := telemetry.Error(ctx, span, err, "error listing customer usage")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 0 - 1
api/server/handlers/cluster/install_agent.go

@@ -112,7 +112,6 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			"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{}{},
 	}

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

@@ -3,7 +3,6 @@ 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"
@@ -100,23 +99,17 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		)
 	}
 
-	// Create Metronome customer and add to starter plan
-	if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
-		customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
+	// Create Lago customer and add to starter plan
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
+		err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating Metronome customer")
+			err = telemetry.Error(ctx, span, err, "error creating usage customer")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
-		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 {
+	if proj.BillingID != "" {
 		_, err = p.Repo().Project().UpdateProject(proj)
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error updating project")

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

@@ -92,19 +92,24 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
-		err = p.Config().BillingManager.MetronomeClient.EndCustomerPlan(ctx, proj.UsageID, proj.UsagePlanID)
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
+		err = p.Config().BillingManager.LagoClient.DeleteCustomer(ctx, proj.ID, proj.EnableSandbox)
 		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
 		}
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
-			telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-			telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
-		)
+	}
+
+	if p.Config().BillingManager.StripeConfigLoaded && proj.GetFeatureFlag(models.BillingEnabled, p.Config().LaunchDarklyClient) {
+		err = p.Config().BillingManager.StripeClient.DeleteCustomer(ctx, proj.BillingID)
+		if err != nil {
+			e := "error deleting stripe customer"
+			err = telemetry.Error(ctx, span, err, e)
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 
 	deletedProject, err := p.Repo().Project().DeleteProject(proj)

+ 7 - 9
api/server/handlers/project/referrals.go

@@ -3,7 +3,6 @@ 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"
@@ -34,14 +33,13 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) ||
-		proj.UsageID == uuid.Nil || !proj.EnableSandbox {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox {
+		c.WriteResult(w, r, "")
 		return
 	}
 
@@ -74,7 +72,7 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h
 	}{
 		Code:              proj.ReferralCode,
 		ReferralCount:     referralCount,
-		MaxAllowedRewards: c.Config().BillingManager.MetronomeClient.MaxReferralRewards,
+		MaxAllowedRewards: c.Config().BillingManager.LagoClient.MaxReferralRewards,
 	}
 
 	c.WriteResult(w, r, referralCodeResponse)

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

@@ -454,34 +454,6 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing/costs -> project.NewListCustomerCostsHandler
-	listCustomerCostsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/billing/costs",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	listCustomerCostsHandler := billing.NewListCustomerCostsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: listCustomerCostsEndpoint,
-		Handler:  listCustomerCostsHandler,
-		Router:   r,
-	})
-
 	// GET /api/projects/{project_id}/billing/invoices -> project.NewListCustomerInvoicesHandler
 	listCustomerInvoicesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -69,11 +69,12 @@ 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"`
-	MetronomeAPIKey      string `env:"METRONOME_API_KEY"`
-	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
-	PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"`
+	StripeSecretKey        string `env:"STRIPE_SECRET_KEY"`
+	StripePublishableKey   string `env:"STRIPE_PUBLISHABLE_KEY"`
+	LagoAPIKey             string `env:"LAGO_API_KEY"`
+	PorterCloudPlanCode    string `env:"PORTER_CLOUD_PLAN_CODE"`
+	PorterStandardPlanCode string `env:"PORTER_STANDARD_PLAN_CODE"`
+	PorterTrialCode        string `env:"PORTER_TRIAL_CODE"`
 
 	// The URL of the webhook to verify ingesting events works
 	IngestStatusWebhookUrl string `env:"INGEST_STATUS_WEBHOOK_URL"`

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

@@ -358,10 +358,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 
 	var (
-		stripeClient     billing.StripeClient
-		stripeEnabled    bool
-		metronomeClient  billing.MetronomeClient
-		metronomeEnabled bool
+		stripeClient  billing.StripeClient
+		stripeEnabled bool
+		lagoClient    billing.LagoClient
+		lagoEnabled   bool
 	)
 	if sc.StripeSecretKey != "" {
 		stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
@@ -371,23 +371,23 @@ 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 != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" {
-		metronomeClient, err = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID)
+	if sc.LagoAPIKey != "" && sc.PorterCloudPlanCode != "" && sc.PorterStandardPlanCode != "" && sc.PorterTrialCode != "" {
+		lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanCode, InstanceEnvConf.ServerConf.PorterStandardPlanCode, InstanceEnvConf.ServerConf.PorterTrialCode)
 		if err != nil {
-			return nil, fmt.Errorf("unable to create metronome client: %w", err)
+			return nil, fmt.Errorf("unable to create Lago client: %w", err)
 		}
-		metronomeEnabled = true
-		res.Logger.Info().Msg("Metronome configuration loaded")
+		lagoEnabled = true
+		res.Logger.Info().Msg("Lago configuration loaded")
 	} else {
-		res.Logger.Info().Msg("METRONOME_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled")
+		res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_CODE, PORTER_STANDARD_PLAN_CODE and PORTER_TRIAL_CODE must be set, all Lago functionality will be disabled")
 	}
 
 	res.Logger.Info().Msg("Creating billing manager")
 	res.BillingManager = billing.Manager{
-		StripeClient:          stripeClient,
-		StripeConfigLoaded:    stripeEnabled,
-		MetronomeClient:       metronomeClient,
-		MetronomeConfigLoaded: metronomeEnabled,
+		StripeClient:       stripeClient,
+		StripeConfigLoaded: stripeEnabled,
+		LagoClient:         lagoClient,
+		LagoConfigLoaded:   lagoEnabled,
 	}
 	res.Logger.Info().Msg("Created billing manager")
 

+ 0 - 231
api/types/billing_metronome.go

@@ -1,231 +0,0 @@
-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"`
-	// NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due
-	NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"`
-	// Trial is the trial period for the plan
-	Trial *TrialSpec `json:"trial_spec,omitempty"`
-}
-
-// TrialSpec is the trial period for the plan
-type TrialSpec struct {
-	LengthInDays int64 `json:"length_in_days"`
-}
-
-// 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"`
-}
-
-// CreateCreditsGrantRequest is the request to create a credit grant for a customer
-type CreateCreditsGrantRequest struct {
-	// CustomerID is the id of the customer
-	CustomerID    uuid.UUID     `json:"customer_id"`
-	UniquenessKey string        `json:"uniqueness_key"`
-	GrantAmount   GrantAmountID `json:"grant_amount"`
-	PaidAmount    PaidAmount    `json:"paid_amount"`
-	Name          string        `json:"name"`
-	ExpiresAt     string        `json:"expires_at"`
-	Priority      int           `json:"priority"`
-	Reason        string        `json:"reason"`
-}
-
-// 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"`
-}
-
-// ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
-type ListCreditGrantsResponse struct {
-	RemainingCredits float64 `json:"remaining_credits"`
-	GrantedCredits   float64 `json:"granted_credits"`
-}
-
-// ListCustomerUsageRequest is the request to list usage for a customer
-type ListCustomerUsageRequest struct {
-	CustomerID       uuid.UUID `json:"customer_id"`
-	BillableMetricID uuid.UUID `json:"billable_metric_id"`
-	WindowSize       string    `json:"window_size"`
-	StartingOn       string    `json:"starting_on,omitempty"`
-	EndingBefore     string    `json:"ending_before,omitempty"`
-	CurrentPeriod    bool      `json:"current_period,omitempty"`
-}
-
-// Usage is the aggregated usage for a customer
-type Usage struct {
-	MetricName   string                `json:"metric_name"`
-	UsageMetrics []CustomerUsageMetric `json:"usage_metrics"`
-}
-
-// CustomerUsageMetric is a metric representing usage for a customer
-type CustomerUsageMetric struct {
-	StartingOn   string  `json:"starting_on"`
-	EndingBefore string  `json:"ending_before"`
-	Value        float64 `json:"value"`
-}
-
-// BillableMetric is defined in Metronome and represents the events that will
-// be ingested
-type BillableMetric struct {
-	ID   uuid.UUID `json:"id"`
-	Name string    `json:"name"`
-}
-
-// ListCustomerCostsRequest is the request to list costs for a customer
-type ListCustomerCostsRequest struct {
-	StartingOn   string `schema:"starting_on"`
-	EndingBefore string `schema:"ending_before"`
-	Limit        int    `schema:"limit"`
-}
-
-// Cost is the cost for a customer in a specific time range
-type Cost struct {
-	StartTimestamp string                    `json:"start_timestamp"`
-	EndTimestamp   string                    `json:"end_timestamp"`
-	CreditTypes    map[string]CreditTypeCost `json:"credit_types"`
-}
-
-// CreditTypeCost is the cost for a specific credit type (e.g. CPU hours)
-type CreditTypeCost struct {
-	Name              string                  `json:"name"`
-	Cost              float64                 `json:"cost"`
-	LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"`
-}
-
-// LineItemBreakdownCost is the cost breakdown by line item
-type LineItemBreakdownCost struct {
-	Name string  `json:"name"`
-	Cost float64 `json:"cost"`
-}
-
-// FormattedCost is the cost for a customer in a specific time range, flattened from the Metronome response
-type FormattedCost struct {
-	StartTimestamp string  `json:"start_timestamp"`
-	EndTimestamp   string  `json:"end_timestamp"`
-	Cost           float64 `json:"cost"`
-}
-
-type Plan struct {
-	ID                  uuid.UUID `json:"id"`
-	PlanID              uuid.UUID `json:"plan_id"`
-	PlanName            string    `json:"plan_name"`
-	PlanDescription     string    `json:"plan_description"`
-	StartingOn          string    `json:"starting_on"`
-	EndingBefore        string    `json:"ending_before"`
-	NetPaymentTermsDays int       `json:"net_payment_terms_days"`
-	TrialInfo           Trial     `json:"trial_info,omitempty"`
-}
-
-// Trial contains the information for a trial period
-type Trial struct {
-	EndingBefore string `json:"ending_before"`
-}
-
-// CreditType is the type of the credit used in the credit grant
-type CreditType struct {
-	Name string `json:"name"`
-	ID   string `json:"id"`
-}
-
-// GrantAmountID represents the amount of credits granted with the credit type ID
-// for the create credits grant request
-type GrantAmountID struct {
-	Amount       float64   `json:"amount"`
-	CreditTypeID uuid.UUID `json:"credit_type_id"`
-}
-
-// GrantAmount represents the amount of credits granted with the credit type
-// for the list credit grants response
-type GrantAmount struct {
-	Amount     float64    `json:"amount"`
-	CreditType CreditType `json:"credit_type"`
-}
-
-// PaidAmount represents the amount paid by the customer
-type PaidAmount struct {
-	Amount       float64   `json:"amount"`
-	CreditTypeID uuid.UUID `json:"credit_type_id"`
-}
-
-// PricingUnit represents the unit of the pricing (e.g. USD, MXN, CPU hours)
-type PricingUnit struct {
-	ID         uuid.UUID `json:"id"`
-	Name       string    `json:"name"`
-	IsCurrency bool      `json:"is_currency"`
-}
-
-// 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 float64 `json:"excluding_pending"`
-	// IncludingPending is the grant's current balance including pending deductions
-	IncludingPending float64 `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      `json:"name"`
-	GrantAmount GrantAmount `json:"grant_amount"`
-	Balance     Balance     `json:"balance"`
-	Reason      string      `json:"reason"`
-	EffectiveAt string      `json:"effective_at"`
-	ExpiresAt   string      `json:"expires_at"`
-}
-
-// BillingEvent represents a Metronome billing event.
-type BillingEvent struct {
-	CustomerID    string                 `json:"customer_id"`
-	EventType     string                 `json:"event_type"`
-	Properties    map[string]interface{} `json:"properties"`
-	TransactionID string                 `json:"transaction_id"`
-	Timestamp     string                 `json:"timestamp"`
-}

+ 0 - 15
api/types/billing_stripe.go

@@ -10,18 +10,3 @@ type PaymentMethod struct {
 	ExpYear      int64  `json:"exp_year"`
 	Default      bool   `json:"is_default"`
 }
-
-// Invoice represents an invoice in the billing system.
-type Invoice struct {
-	// The URL to view the hosted invoice.
-	HostedInvoiceURL string `json:"hosted_invoice_url"`
-	// The status of the invoice.
-	Status string `json:"status"`
-	// RFC 3339 timestamp for when the invoice was created.
-	Created string `json:"created"`
-}
-
-// ListCustomerInvoicesRequest is the request to list invoices for a customer
-type ListCustomerInvoicesRequest struct {
-	Status string `schema:"status"`
-}

+ 81 - 0
api/types/billing_usage.go

@@ -0,0 +1,81 @@
+package types
+
+import "github.com/google/uuid"
+
+// ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
+type ListCreditGrantsResponse struct {
+	RemainingBalanceCents int `json:"remaining_credits"`
+	GrantedBalanceCents   int `json:"granted_credits"`
+}
+
+// ListCustomerUsageRequest is the request to list usage for a customer
+type ListCustomerUsageRequest struct {
+	// PreviousPeriods is the number of previous periods to include in the response.
+	PreviousPeriods int `json:"previous_periods,omitempty"`
+	// CurrentPeriod is whether to return only usage for the current billing period.
+	CurrentPeriod bool `json:"current_period,omitempty"`
+}
+
+// Usage is the aggregated usage for a customer
+type Usage struct {
+	FromDatetime     string        `json:"from_datetime"`
+	ToDatetime       string        `json:"to_datetime"`
+	TotalAmountCents int64         `json:"total_amount_cents"`
+	ChargesUsage     []ChargeUsage `json:"charges_usage"`
+}
+
+// ChargeUsage is the usage for a charge
+type ChargeUsage struct {
+	Units          string         `json:"units"`
+	AmountCents    int64          `json:"amount_cents"`
+	AmountCurrency string         `json:"amount_currency"`
+	BillableMetric BillableMetric `json:"billable_metric"`
+}
+
+// BillableMetric is the metric collected for billing
+type BillableMetric struct {
+	Name string `json:"name"`
+}
+
+// Plan is the plan for a customer
+type Plan struct {
+	ID           string `json:"id"`
+	CustomerID   string `json:"customer_id"`
+	StartingOn   string `json:"starting_on"`
+	EndingBefore string `json:"ending_before"`
+	TrialInfo    Trial  `json:"trial_info,omitempty"`
+}
+
+// Trial contains the information for a trial period
+type Trial struct {
+	EndingBefore string `json:"ending_before"`
+}
+
+// BillingEvent represents a Lago billing event.
+type BillingEvent struct {
+	CustomerID    string                 `json:"customer_id"`
+	EventType     string                 `json:"event_type"`
+	Properties    map[string]interface{} `json:"properties"`
+	TransactionID string                 `json:"transaction_id"`
+	Timestamp     string                 `json:"timestamp"`
+}
+
+// Wallet represents a customer credits wallet
+type Wallet struct {
+	LagoID                   uuid.UUID `json:"lago_id,omitempty"`
+	Status                   string    `json:"status"`
+	BalanceCents             int       `json:"balance_cents,omitempty"`
+	CreditsOngoingBalance    string    `json:"credits_ongoing_balance,omitempty"`
+	OngoingBalanceCents      int       `json:"ongoing_balance_cents,omitempty"`
+	OngoingUsageBalanceCents int       `json:"ongoing_usage_balance_cents,omitempty"`
+}
+
+// Invoice represents an invoice in the billing system.
+type Invoice struct {
+	// The URL to view the hosted invoice.
+	HostedInvoiceURL string `json:"hosted_invoice_url"`
+	// The status of the invoice.
+	Status string `json:"status"`
+	// RFC 3339 timestamp for when the invoice was created.
+	Created string `json:"created"`
+}

+ 1 - 1
api/types/project.go

@@ -39,7 +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"`
+	LagoEnabled                     bool    `json:"metronome_enabled"`
 	InfisicalEnabled                bool    `json:"infisical_enabled"`
 	FreezeEnabled                   bool    `json:"freeze_enabled"`
 	DBEnabled                       bool    `json:"db_enabled"`

+ 16 - 22
dashboard/src/lib/billing/types.tsx

@@ -20,29 +20,31 @@ export type Plan = z.infer<typeof PlanValidator>;
 export const PlanValidator = z
   .object({
     id: z.string(),
-    plan_name: z.string(),
-    plan_description: z.string(),
     starting_on: z.string(),
+    ending_before: z.string(),
     trial_info: TrialValidator,
   })
   .nullable();
 
-export type UsageMetric = z.infer<typeof UsageMetricValidator>;
-export const UsageMetricValidator = z.object({
-  // starting_on and ending_before are RFC 3339 date strings
-  // that represent the timeframe where the metric was ingested.
-  // If the granularity is set per day, the starting_on field
-  // represents the day the metric was ingested.
-  starting_on: z.string(),
-  ending_before: z.string(),
-  value: z.number(),
+export type BillableMetric = z.infer<typeof BillableMetricValidator>;
+export const BillableMetricValidator = z.object({
+  name: z.string(),
+});
+
+export type ChargeUsage = z.infer<typeof ChargeUsageValidator>;
+export const ChargeUsageValidator = z.object({
+  units: z.string(),
+  amount_cents: z.number(),
+  amount_currency: z.string(),
+  billable_metric: BillableMetricValidator,
 });
 
-export type UsageList = Usage[];
 export type Usage = z.infer<typeof UsageValidator>;
 export const UsageValidator = z.object({
-  metric_name: z.string(),
-  usage_metrics: z.array(UsageMetricValidator),
+  from_datetime: z.string(),
+  to_datetime: z.string(),
+  total_amount_cents: z.number(),
+  charges_usage: z.array(ChargeUsageValidator),
 });
 
 export type CreditGrants = z.infer<typeof CreditGrantsValidator>;
@@ -51,14 +53,6 @@ export const CreditGrantsValidator = z.object({
   remaining_credits: z.number(),
 });
 
-export type CostList = Cost[];
-export type Cost = z.infer<typeof CostValidator>;
-export const CostValidator = z.object({
-  start_timestamp: z.string(),
-  end_timestamp: z.string(),
-  cost: z.number(),
-});
-
 export type InvoiceList = Invoice[];
 export type Invoice = z.infer<typeof InvoiceValidator>;
 export const InvoiceValidator = z.object({

+ 10 - 73
dashboard/src/lib/hooks/useMetronome.ts → dashboard/src/lib/hooks/useLago.ts

@@ -2,18 +2,16 @@ import { useContext } from "react";
 import { useQuery } from "@tanstack/react-query";
 
 import {
-  CostValidator,
   CreditGrantsValidator,
   InvoiceValidator,
   PlanValidator,
   ReferralDetailsValidator,
   UsageValidator,
-  type CostList,
   type CreditGrants,
   type InvoiceList,
   type Plan,
   type ReferralDetails,
-  type UsageList,
+  type Usage,
 } from "lib/billing/types";
 
 import api from "shared/api";
@@ -32,11 +30,7 @@ type TGetInvoices = {
 };
 
 type TGetUsage = {
-  usage: UsageList | null;
-};
-
-type TGetCosts = {
-  costs: CostList | null;
+  usageList: Usage[] | null;
 };
 
 type TGetReferralDetails = {
@@ -100,7 +94,6 @@ export const useCustomerPlan = (): TGetPlan => {
           {},
           { project_id: currentProject.id }
         );
-
         const plan = PlanValidator.parse(res.data);
         return plan;
       } catch (error) {
@@ -115,16 +108,15 @@ export const useCustomerPlan = (): TGetPlan => {
 };
 
 export const useCustomerUsage = (
-  startingOn: Date | null,
-  endingBefore: Date | null,
-  windowSize: string
+  previousPeriods: number,
+  currentPeriod: boolean
 ): TGetUsage => {
   const { currentProject } = useContext(Context);
 
   // Fetch customer usage
   const usageReq = useQuery(
-    ["listCustomerUsage", currentProject?.id],
-    async (): Promise<UsageList | null> => {
+    ["listCustomerUsage", currentProject?.id, previousPeriods, currentPeriod],
+    async (): Promise<Usage[] | null> => {
       if (!currentProject?.metronome_enabled) {
         return null;
       }
@@ -133,17 +125,12 @@ export const useCustomerUsage = (
         return null;
       }
 
-      if (startingOn === null || endingBefore === null) {
-        return null;
-      }
-
       try {
         const res = await api.getCustomerUsage(
           "<token>",
           {
-            starting_on: startingOn.toISOString(),
-            ending_before: endingBefore.toISOString(),
-            window_size: windowSize,
+            previous_periods: previousPeriods,
+            current_period: currentPeriod,
           },
           {
             project_id: currentProject?.id,
@@ -158,55 +145,7 @@ export const useCustomerUsage = (
   );
 
   return {
-    usage: usageReq.data ?? null,
-  };
-};
-
-export const useCustomerCosts = (
-  startingOn: Date | null,
-  endingBefore: Date | null,
-  limit: number
-): TGetCosts => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch customer costs
-  const usageReq = useQuery(
-    ["listCustomerCosts", currentProject?.id],
-    async (): Promise<CostList | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id || currentProject.id === -1) {
-        return null;
-      }
-
-      if (startingOn === null || endingBefore === null) {
-        return null;
-      }
-
-      try {
-        const res = await api.getCustomerCosts(
-          "<token>",
-          {},
-          {
-            project_id: currentProject?.id,
-            starting_on: startingOn.toISOString(),
-            ending_before: endingBefore.toISOString(),
-            limit,
-          }
-        );
-
-        const costs = CostValidator.array().parse(res.data);
-        return costs;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    costs: usageReq.data ?? null,
+    usageList: usageReq.data ?? null,
   };
 };
 
@@ -263,9 +202,7 @@ export const useCustomerInvoices = (): TGetInvoices => {
       try {
         const res = await api.getCustomerInvoices(
           "<token>",
-          {
-            status: "paid",
-          },
+          {},
           { project_id: currentProject.id }
         );
 

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

@@ -19,7 +19,7 @@ import Link from "components/porter/Link";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { useCustomerPlan } from "lib/hooks/useMetronome";
+import { useCustomerPlan } from "lib/hooks/useLago";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";

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

@@ -34,7 +34,7 @@ import {
   useDeploymentTargetList,
   type DeploymentTarget,
 } from "lib/hooks/useDeploymentTarget";
-import { useCustomerPlan } from "lib/hooks/useMetronome";
+import { useCustomerPlan } from "lib/hooks/useLago";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";

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

@@ -20,7 +20,7 @@ import {
   useCustomerPlan,
   usePorterCredits,
   useReferralDetails,
-} from "lib/hooks/useMetronome";
+} from "lib/hooks/useLago";
 import {
   checkIfProjectHasPayment,
   usePaymentMethods,

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

@@ -96,15 +96,15 @@ function ProjectSettings(props: any) {
         });
       }
 
-      // if (
-      //   currentProject?.billing_enabled &&
-      //   currentProject?.metronome_enabled
-      // ) {
-      //   tabOpts.push({
-      //     value: "usage",
-      //     label: "Usage",
-      //   });
-      // }
+      if (
+        currentProject?.billing_enabled &&
+        currentProject?.metronome_enabled
+      ) {
+        tabOpts.push({
+          value: "usage",
+          label: "Usage",
+        });
+      }
 
       tabOpts.push({
         value: "additional-settings",

+ 99 - 151
dashboard/src/main/home/project-settings/UsagePage.tsx

@@ -1,169 +1,142 @@
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import dayjs from "dayjs";
 import utc from "dayjs/plugin/utc";
-import styled from "styled-components";
 
+import Container from "components/porter/Container";
 import Fieldset from "components/porter/Fieldset";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { type CostList } from "lib/billing/types";
-import {
-  useCustomerCosts,
-  useCustomerPlan,
-  useCustomerUsage,
-} from "lib/hooks/useMetronome";
-
-import Bars from "./Bars";
+import { useCustomerPlan, useCustomerUsage } from "lib/hooks/useLago";
 
 dayjs.extend(utc);
 
 function UsagePage(): JSX.Element {
   const { plan } = useCustomerPlan();
+  const planStartDate = dayjs.utc(plan?.starting_on).startOf("month");
 
-  const startDate = dayjs.utc(plan?.starting_on);
-  const endDate = dayjs().utc().startOf("day");
-  const numberOfDays = startDate.daysInMonth();
-
-  const [currentPeriodStart, setCurrentPeriodStart] = useState(
-    startDate.toDate()
+  const [currentPeriod, setCurrentPeriod] = useState(
+    dayjs().utc().startOf("month")
   );
-  const [currentPeriodEnd, setCurrentPeriodEnd] = useState(endDate.toDate());
-  const [currentPeriodDuration, setCurrentPeriodDuration] =
-    useState(numberOfDays);
-
-  const { usage } = useCustomerUsage(
-    currentPeriodStart,
-    currentPeriodEnd,
-    "day"
+  const [options, setOptions] = useState<
+    Array<{ value: string; label: string }>
+  >([]);
+  const [previousPeriodCount, setPreviousPeriodCount] = useState(0);
+  const [showCurrentPeriod, setShowCurrentPeriod] = useState(true);
+
+  const { usageList } = useCustomerUsage(
+    previousPeriodCount,
+    showCurrentPeriod
   );
-  const { costs } = useCustomerCosts(
-    currentPeriodStart,
-    currentPeriodEnd,
-    currentPeriodDuration
-  );
-
-  const computeTotalCost = (costs: CostList): number => {
-    const total = costs.reduce((acc, curr) => acc + curr.cost, 0);
-    return parseFloat(total.toFixed(2));
-  };
-
-  const processedUsage = useMemo(() => {
-    const before = usage;
-    const resultMap = new Map();
-
-    before?.forEach(
-      (metric: {
-        metric_name: string;
-        usage_metrics: Array<{ starting_on: string; value: number }>;
-      }) => {
-        const metricName = metric.metric_name.toLowerCase().replace(" ", "_");
-        metric.usage_metrics.forEach(({ starting_on: startingOn, value }) => {
-          if (resultMap.has(startingOn)) {
-            resultMap.get(startingOn)[metricName] = value;
-          } else {
-            resultMap.set(startingOn, {
-              starting_on: new Date(startingOn).toLocaleDateString("en-US", {
-                month: "short",
-                day: "numeric",
-              }),
-              [metricName]: value,
-            });
-          }
-        });
-      }
-    );
 
-    // Convert the map to an array of values
-    const x = Array.from(resultMap.values());
-    return x;
-  }, [usage]);
-
-  const processedCosts = useMemo(() => {
-    return costs
-      ?.map((dailyCost) => {
-        dailyCost.start_timestamp = new Date(
-          dailyCost.start_timestamp
-        ).toLocaleDateString("en-US", {
-          month: "short",
-          day: "numeric",
-        });
-        dailyCost.cost = parseFloat((dailyCost.cost / 100).toFixed(4));
-        return dailyCost;
-      })
-      .filter((dailyCost) => dailyCost.cost > 0);
-  }, [costs]);
+  useEffect(() => {
+    const newOptions = generateOptions();
+    setOptions(newOptions);
+  }, [previousPeriodCount, showCurrentPeriod]);
 
   const generateOptions = (): Array<{ value: string; label: string }> => {
     const options = [];
+    const monthsElapsed = dayjs
+      .utc()
+      .startOf("month")
+      .diff(planStartDate, "month");
 
-    let startDate = dayjs.utc(currentPeriodStart);
-    const endDate = dayjs.utc(currentPeriodEnd);
-
-    while (startDate.isBefore(endDate)) {
-      const nextDate = startDate.add(1, "month");
+    if (monthsElapsed <= 0) {
       options.push({
-        value: startDate.toISOString(),
-        label: `${startDate.format("M/D/YY")} - ${nextDate.format("M/D/YY")}`,
+        value: currentPeriod.month().toString(),
+        label: dayjs().utc().format("MMMM YYYY"),
       });
+      setShowCurrentPeriod(true);
+      return options;
+    }
 
-      startDate = startDate.add(1, "month");
+    setPreviousPeriodCount(monthsElapsed);
+    for (let i = 0; i <= monthsElapsed; i++) {
+      const optionDate = planStartDate.add(i, "month");
+      options.push({
+        value: optionDate.month().toString(),
+        label: optionDate.format("MMMM YYYY"),
+      });
     }
+
     return options;
   };
 
-  const options = generateOptions();
+  const processedUsage = useMemo(() => {
+    if (!usageList?.length) {
+      return null;
+    }
+
+    const periodUsage = usageList.find(
+      (usage) =>
+        dayjs(usage.from_datetime).utc().month() === currentPeriod.month()
+    );
+
+    if (!periodUsage) {
+      return null;
+    }
+
+    const totalCost = periodUsage?.total_amount_cents
+      ? (periodUsage.total_amount_cents / 100).toFixed(4)
+      : "0";
+    const totalCpuHours =
+      periodUsage?.charges_usage.find((x) =>
+        x.billable_metric.name.includes("CPU")
+      )?.units ?? "";
+    const totalGibHours =
+      periodUsage?.charges_usage.find((x) =>
+        x.billable_metric.name.includes("GiB")
+      )?.units ?? "";
+    const currency = periodUsage?.charges_usage[0].amount_currency ?? "";
+
+    if (totalCpuHours === "" || totalGibHours === "") {
+      return null;
+    }
+
+    return {
+      total_cost: totalCost,
+      total_cpu_hours: totalCpuHours,
+      total_gib_hours: totalGibHours,
+      currency,
+    };
+  }, [usageList]);
 
   return (
     <>
       <Select
         options={options}
-        value={currentPeriodStart.toISOString()}
+        value={currentPeriod.month().toString()}
         setValue={(value) => {
-          setCurrentPeriodStart(dayjs.utc(value).toDate());
-          setCurrentPeriodEnd(dayjs.utc(value).add(1, "month").toDate());
-          setCurrentPeriodDuration(dayjs.utc(value).daysInMonth());
+          setCurrentPeriod(dayjs.utc(value));
+          if (dayjs(value).isSame(dayjs(), "month")) {
+            setShowCurrentPeriod(true);
+          } else {
+            setShowCurrentPeriod(false);
+          }
         }}
         width="fit-content"
         prefix={<>Billing period</>}
       />
       <Spacer y={1} />
-      {processedCosts &&
-      processedCosts.length > 0 &&
-      processedUsage &&
-      processedUsage.length > 0 ? (
+      {processedUsage ? (
         <>
-          <BarWrapper>
-            <Total>Total cost: ${computeTotalCost(processedCosts)}</Total>
-            <Bars
-              fill="#C59262"
-              yKey="cost"
-              xKey="start_timestamp"
-              data={processedCosts || []}
-            />
-          </BarWrapper>
+          <Text color="helper">Total usage (selected period):</Text>
           <Spacer y={0.5} />
-          <Flex>
-            <BarWrapper>
-              <Bars
-                title="GiB Hours"
-                fill="#8784D2"
-                yKey="gib_hours"
-                xKey="starting_on"
-                data={processedUsage}
-              />
-            </BarWrapper>
-            <Spacer x={1} inline />
-            <BarWrapper>
-              <Bars
-                title="CPU Hours"
-                fill="#5886E0"
-                yKey="cpu_hours"
-                xKey="starting_on"
-                data={processedUsage}
-              />
-            </BarWrapper>
-          </Flex>
+          <Container row>
+            <Fieldset>
+              <Text size={16}>
+                $ {processedUsage.total_cost} {processedUsage.currency}
+              </Text>
+            </Fieldset>
+            <Spacer inline x={1} />
+            <Fieldset>
+              <Text size={16}>{processedUsage.total_gib_hours} GiB hours</Text>
+            </Fieldset>
+            <Spacer inline x={1} />
+            <Fieldset>
+              <Text size={16}>{processedUsage.total_cpu_hours} CPU hours</Text>
+            </Fieldset>
+          </Container>
         </>
       ) : (
         <Fieldset>
@@ -177,28 +150,3 @@ function UsagePage(): JSX.Element {
 }
 
 export default UsagePage;
-
-const Total = styled.div`
-  position: absolute;
-  top: 20px;
-  left: 55px;
-  font-size: 13px;
-  background: #42444933;
-  backdrop-filter: saturate(150%) blur(8px);
-  padding: 7px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  z-index: 999;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  flex-wrap: wrap;
-`;
-
-const BarWrapper = styled.div`
-  flex: 1;
-  height: 300px;
-  min-width: 450px;
-  position: relative;
-`;

+ 2 - 6
dashboard/src/shared/api.tsx

@@ -3542,9 +3542,7 @@ const getPublishableKey = baseApi<
 
 const getCustomerUsage = baseApi<
   {
-    window_size: string;
-    starting_on?: string;
-    ending_before?: string;
+    previous_periods?: number;
     current_period?: boolean;
   },
   {
@@ -3567,9 +3565,7 @@ const getCustomerCosts = baseApi<
 );
 
 const getCustomerInvoices = baseApi<
-  {
-    status: string;
-  },
+  {},
   {
     project_id?: number;
   }

+ 5 - 2
go.mod

@@ -47,7 +47,7 @@ require (
 	github.com/spf13/viper v1.10.0
 	github.com/stretchr/testify v1.9.0
 	golang.org/x/crypto v0.21.0
-	golang.org/x/net v0.22.0
+	golang.org/x/net v0.23.0
 	golang.org/x/oauth2 v0.18.0
 	google.golang.org/api v0.126.0
 	google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc
@@ -76,6 +76,7 @@ require (
 	github.com/charmbracelet/huh v0.3.0
 	github.com/cloudflare/cloudflare-go v0.76.0
 	github.com/evanphx/json-patch/v5 v5.9.0
+	github.com/getlago/lago-go-client v1.2.0
 	github.com/glebarez/sqlite v1.6.0
 	github.com/go-chi/chi/v5 v5.0.8
 	github.com/golang-jwt/jwt v3.2.1+incompatible
@@ -148,7 +149,9 @@ require (
 	github.com/go-gorp/gorp/v3 v3.0.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-ole/go-ole v1.2.6 // indirect
+	github.com/go-resty/resty/v2 v2.11.0 // indirect
 	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
 	github.com/google/gnostic v0.6.9 // indirect
 	github.com/google/s2a-go v0.1.4 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
@@ -269,7 +272,7 @@ require (
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
-	github.com/google/uuid v1.3.0
+	github.com/google/uuid v1.4.0
 	github.com/googleapis/gax-go/v2 v2.11.0 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gosuri/uitable v0.0.4 // indirect

+ 25 - 5
go.sum

@@ -648,6 +648,8 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo
 github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04=
 github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I=
 github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
+github.com/getlago/lago-go-client v1.2.0 h1:Pl5wD/eTjNdVI+yloAwRWRRB8aDXaxE1sHQ5zVN8WSU=
+github.com/getlago/lago-go-client v1.2.0/go.mod h1:lQL306E/5yNqCxLT+9PYf1wDRv8ye9JbTfQC6sQBH/E=
 github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
 github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -727,6 +729,8 @@ github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
 github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-redis/redis/v8 v8.11.0 h1:O1Td0mQ8UFChQ3N9zFQqo6kTU2cJ+/it88gDB+zg0wo=
 github.com/go-redis/redis/v8 v8.11.0/go.mod h1:DLomh7y2e3ggQXQLd1YgmvIfecPJoFl7WU5SOQ/r06M=
+github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
+github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@@ -788,6 +792,8 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
 github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
 github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
+github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE=
@@ -913,8 +919,9 @@ github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
+github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
 github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -1563,8 +1570,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4=
-github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk=
 github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
@@ -2043,6 +2048,7 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0
 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -2153,8 +2159,11 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -2186,6 +2195,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2318,8 +2328,11 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@@ -2330,6 +2343,9 @@ golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9sn
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
 golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
 golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2343,6 +2359,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2470,6 +2489,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.6/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
 golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 4 - 4
internal/billing/billing.go

@@ -2,8 +2,8 @@ package billing
 
 // Manager contains methods for managing billing for a project
 type Manager struct {
-	StripeClient          StripeClient
-	StripeConfigLoaded    bool
-	MetronomeClient       MetronomeClient
-	MetronomeConfigLoaded bool
+	StripeClient       StripeClient
+	StripeConfigLoaded bool
+	LagoClient         LagoClient
+	LagoConfigLoaded   bool
 }

+ 0 - 578
internal/billing/metronome.go

@@ -1,578 +0,0 @@
-package billing
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/url"
-	"strconv"
-	"time"
-
-	"github.com/google/uuid"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/telemetry"
-)
-
-const (
-	metronomeBaseUrl         = "https://api.metronome.com/v1/"
-	defaultCollectionMethod  = "charge_automatically"
-	defaultMaxRetries        = 10
-	porterStandardTrialDays  = 15
-	defaultRewardAmountCents = 1000
-	defaultPaidAmountCents   = 0
-	maxReferralRewards       = 10
-	maxIngestEventLimit      = 100
-)
-
-// MetronomeClient is the client used to call the Metronome API
-type MetronomeClient struct {
-	ApiKey               string
-	billableMetrics      []types.BillableMetric
-	PorterCloudPlanID    uuid.UUID
-	PorterStandardPlanID uuid.UUID
-
-	// DefaultRewardAmountCents is the default amount in USD cents rewarded to users
-	// who successfully refer a new user
-	DefaultRewardAmountCents float64
-	// DefaultPaidAmountCents is the amount paid by the user to get the credits
-	// grant, if set to 0 it means they are free
-	DefaultPaidAmountCents float64
-	// MaxReferralRewards is the maximum number of referral rewards a user can receive
-	MaxReferralRewards int64
-}
-
-// NewMetronomeClient returns a new Metronome client
-func NewMetronomeClient(metronomeApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client MetronomeClient, err error) {
-	porterCloudPlanUUID, err := uuid.Parse(porterCloudPlanID)
-	if err != nil {
-		return client, err
-	}
-
-	porterStandardPlanUUID, err := uuid.Parse(porterStandardPlanID)
-	if err != nil {
-		return client, err
-	}
-
-	return MetronomeClient{
-		ApiKey:                   metronomeApiKey,
-		PorterCloudPlanID:        porterCloudPlanUUID,
-		PorterStandardPlanID:     porterStandardPlanUUID,
-		DefaultRewardAmountCents: defaultRewardAmountCents,
-		DefaultPaidAmountCents:   defaultPaidAmountCents,
-		MaxReferralRewards:       maxReferralRewards,
-	}, nil
-}
-
-// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan
-func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
-	defer span.End()
-
-	var trialDays uint
-	planID := m.PorterStandardPlanID
-	projID := strconv.FormatUint(uint64(projectID), 10)
-
-	if sandboxEnabled {
-		planID = m.PorterCloudPlanID
-
-		// This is necessary to avoid conflicts with Porter standard projects
-		projID = fmt.Sprintf("porter-cloud-%s", projID)
-	} else {
-		trialDays = porterStandardTrialDays
-	}
-
-	customerID, err = m.createCustomer(ctx, userEmail, projectName, projID, billingID)
-	if err != nil {
-		return customerID, customerPlanID, telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID))
-	}
-
-	customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID, trialDays)
-
-	return customerID, customerPlanID, err
-}
-
-// createCustomer will create the customer in Metronome
-func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID string, billingID string) (customerID uuid.UUID, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer")
-	defer span.End()
-
-	path := "customers"
-
-	customer := types.Customer{
-		Name: projectName,
-		Aliases: []string{
-			projectID,
-		},
-		BillingConfig: types.BillingConfig{
-			BillingProviderType:       "stripe",
-			BillingProviderCustomerID: billingID,
-			StripeCollectionMethod:    defaultCollectionMethod,
-		},
-		CustomFields: map[string]string{
-			"project_id": projectID,
-			"user_email": userEmail,
-		},
-	}
-
-	var result struct {
-		Data types.Customer `json:"data"`
-	}
-
-	_, err = m.do(http.MethodPost, path, "", customer, &result)
-	if err != nil {
-		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
-	}
-	return result.Data.ID, nil
-}
-
-// addCustomerPlan will start the customer on the given plan
-func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID, trialDays uint) (customerPlanID uuid.UUID, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan")
-	defer span.End()
-
-	if customerID == uuid.Nil || planID == uuid.Nil {
-		return customerPlanID, telemetry.Error(ctx, span, err, "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,
-	}
-
-	if trialDays != 0 {
-		req.Trial = &types.TrialSpec{
-			LengthInDays: int64(trialDays),
-		}
-	}
-
-	var result struct {
-		Data struct {
-			CustomerPlanID uuid.UUID `json:"id"`
-		} `json:"data"`
-	}
-
-	_, err = m.do(http.MethodPost, path, "", req, &result)
-	if err != nil {
-		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
-	}
-
-	return result.Data.CustomerPlanID, nil
-}
-
-// ListCustomerPlan will return the current active plan to which the user is subscribed
-func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.UUID) (plan types.Plan, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-customer-plans")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return plan, telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	path := fmt.Sprintf("/customers/%s/plans", customerID)
-
-	var result struct {
-		Data []types.Plan `json:"data"`
-	}
-
-	_, err = m.do(http.MethodGet, path, "", nil, &result)
-	if err != nil {
-		return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
-	}
-
-	if len(result.Data) > 0 {
-		plan = result.Data[0]
-	}
-
-	return plan, nil
-}
-
-// EndCustomerPlan will immediately end the plan for the given customer
-func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UUID, customerPlanID uuid.UUID) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan")
-	defer span.End()
-
-	if customerID == uuid.Nil || customerPlanID == uuid.Nil {
-		return telemetry.Error(ctx, span, err, "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 = m.do(http.MethodPost, path, "", req, nil)
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed to end customer plan")
-	}
-
-	return nil
-}
-
-// ListCustomerCredits will return the total number of credits for the customer
-func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits types.ListCreditGrantsResponse, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return credits, telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	path := "credits/listGrants"
-
-	req := types.ListCreditGrantsRequest{
-		CustomerIDs: []uuid.UUID{
-			customerID,
-		},
-	}
-
-	var result struct {
-		Data []types.CreditGrant `json:"data"`
-	}
-
-	_, err = m.do(http.MethodPost, path, "", req, &result)
-	if err != nil {
-		return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
-	}
-
-	var response types.ListCreditGrantsResponse
-	for _, grant := range result.Data {
-		response.GrantedCredits += grant.GrantAmount.Amount
-		response.RemainingCredits += grant.Balance.IncludingPending
-	}
-
-	return response, nil
-}
-
-// CreateCreditsGrant will create a new credit grant for the customer with the specified amount
-func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, reason string, grantAmount float64, paidAmount float64, expiresAt string) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	path := "credits/createGrant"
-	creditTypeID, err := m.getCreditTypeID(ctx, "USD (cents)")
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed to get credit type id")
-	}
-
-	req := types.CreateCreditsGrantRequest{
-		CustomerID:    customerID,
-		UniquenessKey: uuid.NewString(),
-		GrantAmount: types.GrantAmountID{
-			Amount:       grantAmount,
-			CreditTypeID: creditTypeID,
-		},
-		PaidAmount: types.PaidAmount{
-			Amount:       paidAmount,
-			CreditTypeID: creditTypeID,
-		},
-		Name:      "Porter Credits",
-		Reason:    reason,
-		ExpiresAt: expiresAt,
-		Priority:  1,
-	}
-
-	statusCode, err := m.do(http.MethodPost, path, "", req, nil)
-	if err != nil && statusCode != http.StatusConflict {
-		// a conflict response indicates the grant already exists
-		return telemetry.Error(ctx, span, err, "failed to create credits grant")
-	}
-
-	return nil
-}
-
-// ListCustomerUsage will return the aggregated usage for a customer
-func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return usage, telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	if len(m.billableMetrics) == 0 {
-		billableMetrics, err := m.listBillableMetricIDs(ctx, customerID)
-		if err != nil {
-			return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics")
-		}
-
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)},
-		)
-
-		// Cache billable metric ids for future calls
-		m.billableMetrics = append(m.billableMetrics, billableMetrics...)
-	}
-
-	path := "usage/groups"
-
-	startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore)
-	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, err.Error())
-	}
-
-	baseReq := types.ListCustomerUsageRequest{
-		CustomerID:    customerID,
-		WindowSize:    windowsSize,
-		StartingOn:    startingOnTimestamp,
-		EndingBefore:  endingBeforeTimestamp,
-		CurrentPeriod: currentPeriod,
-	}
-
-	for _, billableMetric := range m.billableMetrics {
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID},
-		)
-
-		var result struct {
-			Data []types.CustomerUsageMetric `json:"data"`
-		}
-
-		baseReq.BillableMetricID = billableMetric.ID
-		_, err = m.do(http.MethodPost, path, "", baseReq, &result)
-		if err != nil {
-			return usage, telemetry.Error(ctx, span, err, "failed to get customer usage")
-		}
-
-		usage = append(usage, types.Usage{
-			MetricName:   billableMetric.Name,
-			UsageMetrics: result.Data,
-		})
-	}
-
-	return usage, nil
-}
-
-// ListCustomerCosts will return the costs for a customer over a time period
-func (m MetronomeClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-customer-costs")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return costs, telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	path := fmt.Sprintf("customers/%s/costs", customerID)
-
-	var result struct {
-		Data []types.Cost `json:"data"`
-	}
-
-	startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore)
-	if err != nil {
-		return nil, telemetry.Error(ctx, span, err, err.Error())
-	}
-
-	queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOnTimestamp, endingBeforeTimestamp, limit)
-
-	_, err = m.do(http.MethodGet, path, queryParams, nil, &result)
-	if err != nil {
-		return costs, telemetry.Error(ctx, span, err, "failed to create credits grant")
-	}
-
-	for _, customerCost := range result.Data {
-		formattedCost := types.FormattedCost{
-			StartTimestamp: customerCost.StartTimestamp,
-			EndTimestamp:   customerCost.EndTimestamp,
-		}
-		for _, creditType := range customerCost.CreditTypes {
-			formattedCost.Cost += creditType.Cost
-		}
-		costs = append(costs, formattedCost)
-	}
-
-	return costs, nil
-}
-
-// IngestEvents sends a list of billing events to Metronome's ingest endpoint
-func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
-	defer span.End()
-
-	if len(events) == 0 {
-		return nil
-	}
-
-	path := "ingest"
-
-	for i := 0; i < len(events); i += maxIngestEventLimit {
-		end := i + maxIngestEventLimit
-		if end > len(events) {
-			end = len(events)
-		}
-
-		batch := events[i:end]
-
-		// Retry each batch to make sure all events are ingested
-		var currentAttempts int
-		for currentAttempts < defaultMaxRetries {
-			statusCode, err := m.do(http.MethodPost, path, "", batch, nil)
-			// Check errors that are not from error http codes
-			if statusCode == 0 && err != nil {
-				return telemetry.Error(ctx, span, err, "failed to ingest billing events")
-			}
-
-			if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
-				return telemetry.Error(ctx, span, err, "unauthorized")
-			}
-
-			// 400 responses should not be retried
-			if statusCode == http.StatusBadRequest {
-				return telemetry.Error(ctx, span, err, "malformed billing events")
-			}
-
-			// Any other status code can be safely retried
-			if statusCode == http.StatusOK {
-				break
-			}
-			currentAttempts++
-		}
-
-		if currentAttempts == defaultMaxRetries {
-			return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
-		}
-	}
-
-	return nil
-}
-
-func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics")
-	defer span.End()
-
-	if customerID == uuid.Nil {
-		return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty")
-	}
-
-	path := fmt.Sprintf("/customers/%s/billable-metrics", customerID)
-
-	var result struct {
-		Data []types.BillableMetric `json:"data"`
-	}
-
-	_, err = m.do(http.MethodGet, path, "", nil, &result)
-	if err != nil {
-		return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
-	}
-
-	return result.Data, nil
-}
-
-func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode string) (creditTypeID uuid.UUID, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "get-credit-type-id")
-	defer span.End()
-
-	path := "/credit-types/list"
-
-	var result struct {
-		Data []types.PricingUnit `json:"data"`
-	}
-
-	_, err = m.do(http.MethodGet, path, "", nil, &result)
-	if err != nil {
-		return creditTypeID, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
-	}
-
-	for _, pricingUnit := range result.Data {
-		if pricingUnit.Name == currencyCode {
-			return pricingUnit.ID, nil
-		}
-	}
-
-	return creditTypeID, telemetry.Error(ctx, span, fmt.Errorf("credit type not found for currency code %s", currencyCode), "failed to find credit type")
-}
-
-// Utility function to parse and adjust times
-func parseAndCheckTimestamps(startingOn string, endingBefore string) (startingOnTimestamp string, endingBeforeTimestamp string, err error) {
-	startingOnTime, err := time.Parse(time.RFC3339, startingOn)
-	if err != nil {
-		return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse starting on time: %w", err)
-	}
-
-	endingBeforeTime, err := time.Parse(time.RFC3339, endingBefore)
-	if err != nil {
-		return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse ending before time: %w", err)
-	}
-
-	if startingOnTime.Equal(endingBeforeTime) {
-		// If starting and ending timestamps are the same, change the ending timestamp to be one day in the future
-		endingBeforeTime = endingBeforeTime.Add(24 * time.Hour)
-	}
-
-	return startingOnTime.Format(time.RFC3339), endingBeforeTime.Format(time.RFC3339), nil
-}
-
-func (m MetronomeClient) do(method string, path string, queryParams string, body interface{}, data interface{}) (statusCode int, err error) {
-	client := http.Client{}
-	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
-	if err != nil {
-		return statusCode, err
-	}
-
-	var bodyJson []byte
-	if body != nil {
-		bodyJson, err = json.Marshal(body)
-		if err != nil {
-			return statusCode, err
-		}
-	}
-
-	// Add raw query parameters to the endpoint
-	if queryParams != "" {
-		endpoint += "?" + queryParams
-	}
-
-	req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
-	if err != nil {
-		return statusCode, err
-	}
-	bearer := "Bearer " + m.ApiKey
-	req.Header.Set("Authorization", bearer)
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return statusCode, err
-	}
-	statusCode = resp.StatusCode
-
-	if resp.StatusCode != http.StatusOK {
-		// If there is an error, try to decode the message
-		var message map[string]string
-		err = json.NewDecoder(resp.Body).Decode(&message)
-		if err != nil {
-			return statusCode, fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
-		}
-		_ = resp.Body.Close()
-
-		return statusCode, fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
-	}
-
-	if data != nil {
-		err = json.NewDecoder(resp.Body).Decode(data)
-		if err != nil {
-			return statusCode, err
-		}
-	}
-	_ = resp.Body.Close()
-
-	return statusCode, nil
-}

+ 2 - 42
internal/billing/stripe.go

@@ -4,13 +4,11 @@ import (
 	"context"
 	"fmt"
 	"strconv"
-	"time"
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/telemetry"
 	"github.com/stripe/stripe-go/v76"
 	"github.com/stripe/stripe-go/v76/customer"
-	"github.com/stripe/stripe-go/v76/invoice"
 	"github.com/stripe/stripe-go/v76/paymentmethod"
 	"github.com/stripe/stripe-go/v76/setupintent"
 )
@@ -42,13 +40,12 @@ func (s StripeClient) CreateCustomer(ctx context.Context, userEmail string, proj
 	stripe.Key = s.SecretKey
 
 	// Create customer if not exists
-	customerName := fmt.Sprintf("project_%s", projectName)
 	projectIDStr := strconv.FormatUint(uint64(projectID), 10)
 	params := &stripe.CustomerParams{
-		Name:  stripe.String(customerName),
+		Name:  stripe.String(projectName),
 		Email: stripe.String(userEmail),
 		Metadata: map[string]string{
-			"porter_project_id": projectIDStr,
+			"project_id": projectIDStr,
 		},
 	}
 
@@ -245,43 +242,6 @@ func (s StripeClient) GetPublishableKey(ctx context.Context) (key string) {
 	return s.PublishableKey
 }
 
-// ListCustomerInvoices will return all invoices for the customer with the given status
-func (s StripeClient) ListCustomerInvoices(ctx context.Context, customerID string, status string) (invoiceList []types.Invoice, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls")
-	defer span.End()
-
-	if customerID == "" {
-		return invoiceList, telemetry.Error(ctx, span, err, "customer id cannot be empty")
-	}
-
-	stripe.Key = s.SecretKey
-
-	params := &stripe.InvoiceListParams{
-		Customer: stripe.String(customerID),
-		Status:   stripe.String(status),
-	}
-
-	result := invoice.List(params)
-
-	for result.Next() {
-		invoice := result.Current().(*stripe.Invoice)
-
-		if invoice == nil {
-			continue
-		}
-
-		createdTimestamp := time.Unix(invoice.Created, 0)
-
-		invoiceList = append(invoiceList, types.Invoice{
-			HostedInvoiceURL: invoice.HostedInvoiceURL,
-			Status:           string(invoice.Status),
-			Created:          createdTimestamp.Format(time.RFC3339),
-		})
-	}
-
-	return invoiceList, nil
-}
-
 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)

+ 545 - 0
internal/billing/usage.go

@@ -0,0 +1,545 @@
+package billing
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/getlago/lago-go-client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+const (
+	lagoBaseURL                = "https://api.getlago.com"
+	defaultStarterCreditsCents = 500
+	defaultRewardAmountCents   = 1000
+	maxReferralRewards         = 10
+	defaultMaxRetries          = 10
+	maxIngestEventLimit        = 100
+
+	// porterStandardTrialDays is the number of days for the trial
+	porterStandardTrialDays = 15
+
+	// These prefixes are used to build the customer and subscription IDs
+	// in Lago. This way we can reuse the project IDs instead of storing
+	// the Lago IDs in the database.
+
+	// TrialIDPrefix is the prefix for the trial ID
+	TrialIDPrefix = "trial"
+	// SubscriptionIDPrefix is the prefix for the subscription ID
+	SubscriptionIDPrefix = "sub"
+	// CustomerIDPrefix is the prefix for the customer ID
+	CustomerIDPrefix = "cus"
+)
+
+// LagoClient is the client used to call the Lago API
+type LagoClient struct {
+	client                 lago.Client
+	lagoApiKey             string
+	PorterCloudPlanCode    string
+	PorterStandardPlanCode string
+	PorterTrialCode        string
+
+	// DefaultRewardAmountCents is the default amount in USD cents rewarded to users
+	// who successfully refer a new user
+	DefaultRewardAmountCents int64
+	// MaxReferralRewards is the maximum number of referral rewards a user can receive
+	MaxReferralRewards int64
+}
+
+// NewLagoClient returns a new Lago client
+func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) {
+	lagoClient := lago.New().SetApiKey(lagoApiKey)
+
+	if lagoClient == nil {
+		return client, fmt.Errorf("failed to create lago client")
+	}
+
+	return LagoClient{
+		lagoApiKey:               lagoApiKey,
+		client:                   *lagoClient,
+		PorterCloudPlanCode:      porterCloudPlanCode,
+		PorterStandardPlanCode:   porterStandardPlanCode,
+		PorterTrialCode:          porterTrialCode,
+		DefaultRewardAmountCents: defaultRewardAmountCents,
+		MaxReferralRewards:       maxReferralRewards,
+	}, nil
+}
+
+// CreateCustomerWithPlan will create the customer in Lago and immediately add it to the plan
+func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
+	defer span.End()
+
+	if projectID == 0 {
+		return telemetry.Error(ctx, span, err, "project id empty")
+	}
+
+	customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error while creating customer")
+	}
+
+	trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled)
+	subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled)
+
+	// The dates need to be at midnight UTC
+	now := time.Now().UTC()
+	now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+	trialEndTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(time.Hour * 24 * porterStandardTrialDays).UTC()
+
+	if sandboxEnabled {
+		err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode))
+		}
+
+		walletName := "Porter Credits"
+		expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour)
+
+		err = m.CreateCreditsGrant(ctx, projectID, walletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "error while creating starter credits grant")
+		}
+		return nil
+	}
+
+	// First, start the new customer on the trial
+	err = m.addCustomerPlan(ctx, customerID, m.PorterTrialCode, trialID, &now, &trialEndTime)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, fmt.Sprintf("error while starting customer trial %s", m.PorterTrialCode))
+	}
+
+	// Then, add the customer to the actual plan. The date of the subscription will be the end of the trial
+	err = m.addCustomerPlan(ctx, customerID, m.PorterStandardPlanCode, subscriptionID, &trialEndTime, nil)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterStandardPlanCode))
+	}
+
+	return err
+}
+
+// CheckIfCustomerExists will check if the customer exists in Lago
+func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists")
+	defer span.End()
+
+	if projectID == 0 {
+		return exists, telemetry.Error(ctx, span, err, "project id empty")
+	}
+
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
+	_, lagoErr := m.client.Customer().Get(ctx, customerID)
+	if lagoErr != nil {
+		return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer")
+	}
+
+	return true, nil
+}
+
+// GetCustomeActivePlan will return the active plan for the customer
+func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-active-subscription")
+	defer span.End()
+
+	if projectID == 0 {
+		return plan, telemetry.Error(ctx, span, err, "project id empty")
+	}
+
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	subscriptionListInput := lago.SubscriptionListInput{
+		ExternalCustomerID: customerID,
+	}
+
+	activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput)
+	if lagoErr != nil {
+		return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription")
+	}
+
+	if activeSubscriptions == nil {
+		return plan, telemetry.Error(ctx, span, err, "no active subscriptions found")
+	}
+
+	for _, subscription := range activeSubscriptions.Subscriptions {
+		if subscription.Status != lago.SubscriptionStatusActive {
+			continue
+		}
+
+		plan.ID = subscription.ExternalID
+		plan.CustomerID = subscription.ExternalCustomerID
+		plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339)
+
+		if subscription.EndingAt != nil {
+			plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+		}
+
+		if strings.Contains(subscription.ExternalID, TrialIDPrefix) {
+			plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339)
+		}
+
+		break
+	}
+
+	return plan, nil
+}
+
+// DeleteCustomer will delete the customer and terminate all subscriptions
+func (m LagoClient) DeleteCustomer(ctx context.Context, projectID uint, sandboxEnabled bool) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "delete-lago-customer")
+	defer span.End()
+
+	if projectID == 0 {
+		return telemetry.Error(ctx, span, err, "subscription id empty")
+	}
+
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+	_, lagoErr := m.client.Customer().Delete(ctx, customerID)
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription")
+	}
+
+	return nil
+}
+
+// ListCustomerCredits will return the total number of credits for the customer
+func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, sandboxEnabled bool) (credits types.ListCreditGrantsResponse, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
+	defer span.End()
+
+	if projectID == 0 {
+		return credits, telemetry.Error(ctx, span, err, "project id empty")
+	}
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+
+	walletList, err := m.listCustomerWallets(ctx, customerID)
+	if err != nil {
+		return credits, telemetry.Error(ctx, span, err, "failed to list customer wallets")
+	}
+
+	var response types.ListCreditGrantsResponse
+	for _, wallet := range walletList {
+		if wallet.Status != string(lago.Active) {
+			continue
+		}
+
+		response.GrantedBalanceCents += wallet.BalanceCents
+		response.RemainingBalanceCents += wallet.OngoingBalanceCents
+	}
+
+	return response, nil
+}
+
+// CreateCreditsGrant will create a new credit grant for the customer with the specified amount
+func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
+	defer span.End()
+
+	if projectID == 0 {
+		return telemetry.Error(ctx, span, err, "project id empty")
+	}
+
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+
+	walletList, err := m.listCustomerWallets(ctx, customerID)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "failed to list customer wallets")
+	}
+
+	if len(walletList) == 0 {
+		walletInput := &lago.WalletInput{
+			ExternalCustomerID: customerID,
+			Name:               name,
+			Currency:           lago.USD,
+			GrantedCredits:     strconv.FormatInt(grantAmount, 10),
+			// Rate is 1 credit = 1 cent
+			RateAmount:   "0.01",
+			ExpirationAt: expiresAt,
+		}
+
+		_, lagoErr := m.client.Wallet().Create(ctx, walletInput)
+		if lagoErr != nil {
+			return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant")
+		}
+
+		return nil
+	}
+
+	// Currently only one wallet per customer is supported in Lago
+	wallet := walletList[0]
+	walletTransactionInput := &lago.WalletTransactionInput{
+		WalletID:       wallet.LagoID.String(),
+		GrantedCredits: strconv.FormatInt(grantAmount, 10),
+	}
+
+	// If the wallet already exists, we need to update the balance
+	_, lagoErr := m.client.WalletTransaction().Create(ctx, walletTransactionInput)
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to update credits grant")
+	}
+
+	return nil
+}
+
+// ListCustomerUsage will return the aggregated usage for a customer
+func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool, previousPeriods int) (usageList []types.Usage, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
+	defer span.End()
+
+	if subscriptionID == "" {
+		return usageList, telemetry.Error(ctx, span, err, "subscription id empty")
+	}
+
+	if currentPeriod {
+		customerUsageInput := &lago.CustomerUsageInput{
+			ExternalSubscriptionID: subscriptionID,
+		}
+
+		currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput)
+		if lagoErr != nil {
+			return usageList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage")
+		}
+
+		if currentUsage == nil {
+			return usageList, nil
+		}
+
+		usage := createUsageFromLagoUsage(*currentUsage)
+		usageList = append(usageList, usage)
+	} else {
+		url := fmt.Sprintf("%s/api/v1/customers/%s/past_usage?external_subscription_id=%s&periods_count=%d", lagoBaseURL, customerID, subscriptionID, previousPeriods)
+		req, err := http.NewRequest("GET", url, nil)
+		if err != nil {
+			return usageList, telemetry.Error(ctx, span, err, "failed to create wallets request")
+		}
+
+		req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
+
+		client := &http.Client{}
+		resp, err := client.Do(req)
+		if err != nil {
+			return usageList, telemetry.Error(ctx, span, err, "failed to get customer credits")
+		}
+
+		var previousUsage lago.CustomerPastUsageResult
+		err = json.NewDecoder(resp.Body).Decode(&previousUsage)
+		if err != nil {
+			return usageList, telemetry.Error(ctx, span, err, "failed to decode usage list response")
+		}
+
+		for _, pastUsage := range previousUsage.UsagePeriods {
+			usage := createUsageFromLagoUsage(pastUsage)
+			usageList = append(usageList, usage)
+		}
+	}
+	return usageList, nil
+}
+
+// IngestEvents sends a list of billing events to Lago's ingest endpoint
+func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ingest-billing-events")
+	defer span.End()
+
+	if len(events) == 0 {
+		return nil
+	}
+
+	for i := 0; i < len(events); i += maxIngestEventLimit {
+		end := i + maxIngestEventLimit
+		if end > len(events) {
+			end = len(events)
+		}
+
+		batch := events[i:end]
+		var batchInput []lago.EventInput
+		for i := range batch {
+			event := lago.EventInput{
+				TransactionID:          batch[i].TransactionID,
+				ExternalSubscriptionID: subscriptionID,
+				Code:                   batch[i].EventType,
+				Properties:             batch[i].Properties,
+			}
+			batchInput = append(batchInput, event)
+		}
+
+		// Retry each batch to make sure all events are ingested
+		var currentAttempts int
+		for currentAttempts := 0; currentAttempts < defaultMaxRetries; currentAttempts++ {
+			_, lagoErr := m.client.Event().Batch(ctx, &batchInput)
+			if lagoErr == nil {
+				return nil
+			}
+		}
+
+		if currentAttempts == defaultMaxRetries {
+			return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
+		}
+	}
+
+	return nil
+}
+
+// ListCustomerFinalizedInvoices will return all finalized invoices for the customer
+func (m LagoClient) ListCustomerFinalizedInvoices(ctx context.Context, projectID uint, enableSandbox bool) (invoiceList []types.Invoice, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-invoices")
+	defer span.End()
+
+	if projectID == 0 {
+		return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty")
+	}
+
+	customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox)
+	invoiceListInput := &lago.InvoiceListInput{
+		ExternalCustomerID: customerID,
+		Status:             lago.InvoiceStatusFinalized,
+	}
+
+	invoices, lagoErr := m.client.Invoice().GetList(ctx, invoiceListInput)
+	if lagoErr != nil {
+		return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to list invoices")
+	}
+
+	for _, invoice := range invoices.Invoices {
+		invoiceReq, lagoErr := m.client.Invoice().Download(ctx, invoice.LagoID.String())
+		if lagoErr != nil {
+			return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to download invoice")
+		}
+
+		var fileURL string
+		if invoiceReq == nil {
+			fileURL = invoice.FileURL
+		} else {
+			fileURL = invoiceReq.FileURL
+		}
+
+		invoiceList = append(invoiceList, types.Invoice{
+			HostedInvoiceURL: fileURL,
+			Status:           string(invoice.Status),
+			Created:          invoice.IssuingDate,
+		})
+	}
+
+	return invoiceList, nil
+}
+
+// createCustomer will create the customer in Lago
+func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "create-lago-customer")
+	defer span.End()
+
+	customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
+
+	customerInput := &lago.CustomerInput{
+		ExternalID: customerID,
+		Name:       projectName,
+		Email:      userEmail,
+		BillingConfiguration: lago.CustomerBillingConfigurationInput{
+			PaymentProvider:    lago.PaymentProviderStripe,
+			ProviderCustomerID: billingID,
+			Sync:               false,
+			SyncWithProvider:   false,
+		},
+	}
+
+	_, lagoErr := m.client.Customer().Create(ctx, customerInput)
+	if lagoErr != nil {
+		return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer")
+	}
+	return customerID, nil
+}
+
+// addCustomerPlan will create a plan subscription for the customer
+func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan")
+	defer span.End()
+
+	if customerID == "" || planID == "" {
+		return telemetry.Error(ctx, span, err, "project and plan id are required")
+	}
+
+	subscriptionInput := &lago.SubscriptionInput{
+		ExternalCustomerID: customerID,
+		ExternalID:         subscriptionID,
+		PlanCode:           planID,
+		SubscriptionAt:     startingAt,
+		EndingAt:           endingAt,
+		BillingTime:        lago.Calendar,
+	}
+
+	_, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput)
+	if lagoErr != nil {
+		return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription")
+	}
+
+	return nil
+}
+
+func (m LagoClient) listCustomerWallets(ctx context.Context, customerID string) (walletList []types.Wallet, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-lago-customer-wallets")
+	defer span.End()
+
+	// We manually do the request in this function because the Lago client has an issue
+	// with types for this specific request
+	url := fmt.Sprintf("%s/api/v1/wallets?external_customer_id=%s", lagoBaseURL, customerID)
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return walletList, telemetry.Error(ctx, span, err, "failed to create wallets list request")
+	}
+
+	req.Header.Set("Authorization", "Bearer "+m.lagoApiKey)
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return walletList, telemetry.Error(ctx, span, err, "failed to get customer credits")
+	}
+
+	response := struct {
+		Wallets []types.Wallet `json:"wallets"`
+	}{}
+
+	err = json.NewDecoder(resp.Body).Decode(&response)
+	if err != nil {
+		return walletList, telemetry.Error(ctx, span, err, "failed to decode wallet list response")
+	}
+
+	err = resp.Body.Close()
+	if err != nil {
+		return walletList, telemetry.Error(ctx, span, err, "failed to close response body")
+	}
+
+	return response.Wallets, nil
+}
+
+func createUsageFromLagoUsage(lagoUsage lago.CustomerUsage) types.Usage {
+	usage := types.Usage{}
+	usage.FromDatetime = lagoUsage.FromDatetime.Format(time.RFC3339)
+	usage.ToDatetime = lagoUsage.ToDatetime.Format(time.RFC3339)
+	usage.TotalAmountCents = int64(lagoUsage.TotalAmountCents)
+	usage.ChargesUsage = make([]types.ChargeUsage, len(lagoUsage.ChargesUsage))
+
+	for i, charge := range lagoUsage.ChargesUsage {
+		usage.ChargesUsage[i] = types.ChargeUsage{
+			Units:          charge.Units,
+			AmountCents:    int64(charge.AmountCents),
+			AmountCurrency: string(charge.AmountCurrency),
+			BillableMetric: types.BillableMetric{
+				Name: charge.BillableMetric.Name,
+			},
+		}
+	}
+
+	return usage
+}
+
+func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string {
+	if sandboxEnabled {
+		return fmt.Sprintf("cloud_%s_%d", prefix, projectID)
+	}
+
+	return fmt.Sprintf("%s_%d", prefix, projectID)
+}

+ 5 - 16
internal/models/project.go

@@ -5,7 +5,6 @@ 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"
@@ -29,8 +28,8 @@ const (
 	// BillingEnabled enables the "Billing" tab and all Stripe integrations
 	BillingEnabled FeatureFlagLabel = "billing_enabled"
 
-	// MetronomeEnabled enables all Metronome business logic
-	MetronomeEnabled FeatureFlagLabel = "metronome_enabled"
+	// LagoEnabled enables all Lago business logic. This is kept as "metronome_enabled" for compatibility reasons
+	LagoEnabled FeatureFlagLabel = "metronome_enabled"
 
 	// InfisicalEnabled enables the Infisical secrets operator integration
 	InfisicalEnabled FeatureFlagLabel = "infisical_enabled"
@@ -107,7 +106,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	BetaFeaturesEnabled:             false,
 	CapiProvisionerEnabled:          true,
 	BillingEnabled:                  false,
-	MetronomeEnabled:                false,
+	LagoEnabled:                     false,
 	InfisicalEnabled:                false,
 	FreezeEnabled:                   false,
 	DBEnabled:                       false,
@@ -147,14 +146,7 @@ type Project struct {
 	Roles []Role `json:"roles"`
 
 	// BillingID corresponds to the id generated by the billing provider
-	BillingID      string
-	BillingEnabled bool
-
-	// 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
+	BillingID string
 
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
@@ -250,8 +242,6 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return p.AzureEnabled
 		case "capi_provisioner_enabled":
 			return p.CapiProvisionerEnabled
-		case "billing_enabled":
-			return p.BillingEnabled
 		case "db_enabled":
 			return false
 		case "enable_reprovision":
@@ -321,7 +311,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),
+		LagoEnabled:                     p.GetFeatureFlag(LagoEnabled, launchDarklyClient),
 		InfisicalEnabled:                p.GetFeatureFlag(InfisicalEnabled, launchDarklyClient),
 		FreezeEnabled:                   p.GetFeatureFlag(FreezeEnabled, launchDarklyClient),
 		DBEnabled:                       p.GetFeatureFlag(DBEnabled, launchDarklyClient),
@@ -364,7 +354,6 @@ func (p *Project) ToProjectListType() *types.ProjectList {
 		// note: all of these fields should be considered deprecated
 		// in an api response
 		Roles:                  roles,
-		BillingEnabled:         p.BillingEnabled,
 		PreviewEnvsEnabled:     p.PreviewEnvsEnabled,
 		RDSDatabasesEnabled:    p.RDSDatabasesEnabled,
 		ManagedInfraEnabled:    p.ManagedInfraEnabled,

+ 9 - 6
zarf/helm/.serverenv

@@ -73,14 +73,17 @@ 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=
+# LAGO_API_KEY is used to create customers in Lago. If empty all functionality will be disabled.
+LAGO_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=
+# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_CLOUD_PLAN_CODE=
 
-# PORTER_STANDARD_PLAN_ID is the id of the standard plan in Metronome. Only used if METRONOME_API_KEY is set
-PORTER_STANDARD_PLAN_ID=
+# PORTER_STANDARD_PLAN_CODE is the id of the standard plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_STANDARD_PLAN_CODE=
+
+# PORTER_TRIAL_CODE is the id of the trial plan in Lago. Only used if LAGO_API_KEY is set
+PORTER_TRIAL_CODE=
 
 # UPSTASH_ENABLED is used to enable the Upstash integration
 UPSTASH_ENABLED=false