From 99dfe64dcdc520de93b4c5d53c0efe58da8e7bc0 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 15 May 2024 15:03:16 -0400 Subject: [PATCH] Fix wallets and referral credits --- api/server/handlers/project/referrals.go | 10 +- api/types/billing_usage.go | 12 +- .../main/home/project-settings/UsagePage.tsx | 7 +- internal/billing/usage.go | 129 +++++++++++------- 4 files changed, 95 insertions(+), 63 deletions(-) diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 859410bcd0..c4ba92f2fb 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -33,13 +33,13 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + 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)}, + ) + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !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)}, - ) return } diff --git a/api/types/billing_usage.go b/api/types/billing_usage.go index 2c337aa38a..b5eb3d1922 100644 --- a/api/types/billing_usage.go +++ b/api/types/billing_usage.go @@ -1,5 +1,7 @@ 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"` @@ -60,10 +62,12 @@ type BillingEvent struct { // Wallet represents a customer credits wallet type Wallet struct { - Status string `json:"status"` - BalanceCents int `json:"balance_cents,omitempty"` - OngoingBalanceCents int `json:"ongoing_balance_cents,omitempty"` - OngoingUsageBalanceCents int `json:"ongoing_usage_balance_cents,omitempty"` + 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. diff --git a/dashboard/src/main/home/project-settings/UsagePage.tsx b/dashboard/src/main/home/project-settings/UsagePage.tsx index 78384445b7..0a6112657b 100644 --- a/dashboard/src/main/home/project-settings/UsagePage.tsx +++ b/dashboard/src/main/home/project-settings/UsagePage.tsx @@ -78,7 +78,7 @@ function UsagePage(): JSX.Element { 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") @@ -88,6 +88,11 @@ function UsagePage(): JSX.Element { 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, diff --git a/internal/billing/usage.go b/internal/billing/usage.go index cbad5b3170..4cbb2b0290 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -99,10 +99,10 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode)) } - starterWalletName := "Free Starter Credits" + walletName := "Porter Credits" expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour) - err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled) + err = m.CreateCreditsGrant(ctx, projectID, walletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled) if err != nil { return telemetry.Error(ctx, span, err, "error while creating starter credits grant") } @@ -216,34 +216,13 @@ func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, san } customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) - // 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 credits, 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) + walletList, err := m.listCustomerWallets(ctx, customerID) if err != nil { - return credits, telemetry.Error(ctx, span, err, "failed to get customer credits") - } - - type ListWalletsResponse struct { - Wallets []types.Wallet `json:"wallets"` - } - - var walletList ListWalletsResponse - err = json.NewDecoder(resp.Body).Decode(&walletList) - if err != nil { - return credits, telemetry.Error(ctx, span, err, "failed to decode wallet list response") + return credits, telemetry.Error(ctx, span, err, "failed to list customer wallets") } var response types.ListCreditGrantsResponse - for _, wallet := range walletList.Wallets { + for _, wallet := range walletList { if wallet.Status != string(lago.Active) { continue } @@ -252,11 +231,6 @@ func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, san response.RemainingBalanceCents += wallet.OngoingBalanceCents } - err = resp.Body.Close() - if err != nil { - return credits, telemetry.Error(ctx, span, err, "failed to close response body") - } - return response, nil } @@ -270,19 +244,42 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name } customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) - 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, + + 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 } - _, lagoErr := m.client.Wallet().Create(ctx, walletInput) + // 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 create credits grant") + return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to update credits grant") } return nil @@ -360,20 +357,9 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve batch := events[i:end] var batchInput []lago.EventInput for i := range batch { - externalSubscriptionID := subscriptionID - if enableSandbox { - // This hack has to be done because we can't infer the project id from the - // context in Porter Cloud - customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64) - if err != nil { - return telemetry.Error(ctx, span, err, "failed to parse customer ID") - } - externalSubscriptionID = m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) - } - event := lago.EventInput{ TransactionID: batch[i].TransactionID, - ExternalSubscriptionID: externalSubscriptionID, + ExternalSubscriptionID: subscriptionID, Code: batch[i].EventType, Properties: batch[i].Properties, } @@ -492,6 +478,43 @@ func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, plan 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)