From a29f0b1ebcff4d88c81829b60223141cf32ebb7f Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 15 May 2024 15:08:26 -0400 Subject: [PATCH] Lago integration (#4620) Co-authored-by: jusrhee --- api/server/handlers/billing/create.go | 13 +- api/server/handlers/billing/ingest.go | 34 +- api/server/handlers/billing/invoices.go | 24 +- api/server/handlers/billing/list.go | 39 +- api/server/handlers/billing/plan.go | 100 +-- api/server/handlers/cluster/install_agent.go | 1 - api/server/handlers/project/create.go | 17 +- api/server/handlers/project/delete.go | 19 +- api/server/handlers/project/referrals.go | 16 +- api/server/router/project.go | 28 - api/server/shared/config/env/envconfs.go | 11 +- api/server/shared/config/loader/loader.go | 28 +- api/types/billing_metronome.go | 231 ------- api/types/billing_stripe.go | 15 - api/types/billing_usage.go | 81 +++ api/types/project.go | 2 +- dashboard/src/lib/billing/types.tsx | 38 +- .../lib/hooks/{useMetronome.ts => useLago.ts} | 83 +-- dashboard/src/main/home/Home.tsx | 2 +- .../src/main/home/app-dashboard/apps/Apps.tsx | 2 +- .../home/project-settings/BillingPage.tsx | 2 +- .../home/project-settings/ProjectSettings.tsx | 18 +- .../main/home/project-settings/UsagePage.tsx | 250 +++----- dashboard/src/shared/api.tsx | 8 +- go.mod | 7 +- go.sum | 30 +- internal/billing/billing.go | 8 +- internal/billing/metronome.go | 578 ------------------ internal/billing/stripe.go | 44 +- internal/billing/usage.go | 545 +++++++++++++++++ internal/models/project.go | 21 +- zarf/helm/.serverenv | 15 +- 32 files changed, 943 insertions(+), 1367 deletions(-) delete mode 100644 api/types/billing_metronome.go create mode 100644 api/types/billing_usage.go rename dashboard/src/lib/hooks/{useMetronome.ts => useLago.ts} (72%) delete mode 100644 internal/billing/metronome.go create mode 100644 internal/billing/usage.go diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 881d8856d8..340233fb50 100644 --- a/api/server/handlers/billing/create.go +++ b/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") } diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index 697984c152..811d9b8ce7 100644 --- a/api/server/handlers/billing/ingest.go +++ b/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)) diff --git a/api/server/handlers/billing/invoices.go b/api/server/handlers/billing/invoices.go index 1b153a69cd..42fc64a04b 100644 --- a/api/server/handlers/billing/invoices.go +++ b/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) } diff --git a/api/server/handlers/billing/list.go b/api/server/handlers/billing/list.go index b344749072..4dc9b75760 100644 --- a/api/server/handlers/billing/list.go +++ b/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 diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 4d72a6a434..b321e77772 100644 --- a/api/server/handlers/billing/plan.go +++ b/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 } diff --git a/api/server/handlers/cluster/install_agent.go b/api/server/handlers/cluster/install_agent.go index a8870c46f4..7bfd128300 100644 --- a/api/server/handlers/cluster/install_agent.go +++ b/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{}{}, } diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index af891b2cec..bd2cfc5f0a 100644 --- a/api/server/handlers/project/create.go +++ b/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") diff --git a/api/server/handlers/project/delete.go b/api/server/handlers/project/delete.go index f6fca3b937..7903024238 100644 --- a/api/server/handlers/project/delete.go +++ b/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) diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 80669a2f95..c4ba92f2fb 100644 --- a/api/server/handlers/project/referrals.go +++ b/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) diff --git a/api/server/router/project.go b/api/server/router/project.go index 5a922f33bb..06c186f59a 100644 --- a/api/server/router/project.go +++ b/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{ diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index 683fdc1f03..b388d22614 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/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"` diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index 17b6f1b8b9..be9cf93cf0 100644 --- a/api/server/shared/config/loader/loader.go +++ b/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") diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go deleted file mode 100644 index 4ce79daf2d..0000000000 --- a/api/types/billing_metronome.go +++ /dev/null @@ -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"` -} diff --git a/api/types/billing_stripe.go b/api/types/billing_stripe.go index 7238086be0..e50f003ef8 100644 --- a/api/types/billing_stripe.go +++ b/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"` -} diff --git a/api/types/billing_usage.go b/api/types/billing_usage.go new file mode 100644 index 0000000000..b5eb3d1922 --- /dev/null +++ b/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"` +} diff --git a/api/types/project.go b/api/types/project.go index ecf3c48d13..99cc101231 100644 --- a/api/types/project.go +++ b/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"` diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 469ba1c9db..4415f8cbe8 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -20,29 +20,31 @@ export type Plan = z.infer; 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; -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; +export const BillableMetricValidator = z.object({ + name: z.string(), +}); + +export type ChargeUsage = z.infer; +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; 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; @@ -51,14 +53,6 @@ export const CreditGrantsValidator = z.object({ remaining_credits: z.number(), }); -export type CostList = Cost[]; -export type Cost = z.infer; -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; export const InvoiceValidator = z.object({ diff --git a/dashboard/src/lib/hooks/useMetronome.ts b/dashboard/src/lib/hooks/useLago.ts similarity index 72% rename from dashboard/src/lib/hooks/useMetronome.ts rename to dashboard/src/lib/hooks/useLago.ts index a2fd40590f..6e7e45677f 100644 --- a/dashboard/src/lib/hooks/useMetronome.ts +++ b/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 => { + ["listCustomerUsage", currentProject?.id, previousPeriods, currentPeriod], + async (): Promise => { 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( "", { - 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 => { - 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( - "", - {}, - { - 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( "", - { - status: "paid", - }, + {}, { project_id: currentProject.id } ); diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 0f4034e571..b2ff094b4a 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/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"; diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index df8613f938..1cf262c592 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/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"; diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 2ce4b5f49c..c0a8d2e530 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/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, diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index 7b20c3391b..3c73f0fc93 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/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", diff --git a/dashboard/src/main/home/project-settings/UsagePage.tsx b/dashboard/src/main/home/project-settings/UsagePage.tsx index cd92ee0e59..0a6112657b 100644 --- a/dashboard/src/main/home/project-settings/UsagePage.tsx +++ b/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 ( <>