Ver código fonte

Fix wallets and referral credits

Mauricio Araujo 2 anos atrás
pai
commit
99dfe64dcd

+ 5 - 5
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)
 	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 {
 	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox {
 		c.WriteResult(w, r, "")
 		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
 		return
 	}
 	}
 
 

+ 8 - 4
api/types/billing_usage.go

@@ -1,5 +1,7 @@
 package types
 package types
 
 
+import "github.com/google/uuid"
+
 // ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
 // ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
 type ListCreditGrantsResponse struct {
 type ListCreditGrantsResponse struct {
 	RemainingBalanceCents int `json:"remaining_credits"`
 	RemainingBalanceCents int `json:"remaining_credits"`
@@ -60,10 +62,12 @@ type BillingEvent struct {
 
 
 // Wallet represents a customer credits wallet
 // Wallet represents a customer credits wallet
 type Wallet struct {
 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.
 // Invoice represents an invoice in the billing system.

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

@@ -78,7 +78,7 @@ function UsagePage(): JSX.Element {
 
 
     const totalCost = periodUsage?.total_amount_cents
     const totalCost = periodUsage?.total_amount_cents
       ? (periodUsage.total_amount_cents / 100).toFixed(4)
       ? (periodUsage.total_amount_cents / 100).toFixed(4)
-      : "";
+      : "0";
     const totalCpuHours =
     const totalCpuHours =
       periodUsage?.charges_usage.find((x) =>
       periodUsage?.charges_usage.find((x) =>
         x.billable_metric.name.includes("CPU")
         x.billable_metric.name.includes("CPU")
@@ -88,6 +88,11 @@ function UsagePage(): JSX.Element {
         x.billable_metric.name.includes("GiB")
         x.billable_metric.name.includes("GiB")
       )?.units ?? "";
       )?.units ?? "";
     const currency = periodUsage?.charges_usage[0].amount_currency ?? "";
     const currency = periodUsage?.charges_usage[0].amount_currency ?? "";
+
+    if (totalCpuHours === "" || totalGibHours === "") {
+      return null;
+    }
+
     return {
     return {
       total_cost: totalCost,
       total_cost: totalCost,
       total_cpu_hours: totalCpuHours,
       total_cpu_hours: totalCpuHours,

+ 76 - 53
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))
 			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)
 		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 {
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "error while creating starter credits grant")
 			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)
 	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 {
 	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
 	var response types.ListCreditGrantsResponse
-	for _, wallet := range walletList.Wallets {
+	for _, wallet := range walletList {
 		if wallet.Status != string(lago.Active) {
 		if wallet.Status != string(lago.Active) {
 			continue
 			continue
 		}
 		}
@@ -252,11 +231,6 @@ func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, san
 		response.RemainingBalanceCents += wallet.OngoingBalanceCents
 		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
 	return response, nil
 }
 }
 
 
@@ -270,19 +244,42 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name
 	}
 	}
 
 
 	customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled)
 	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 {
 	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
 	return nil
@@ -360,20 +357,9 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve
 		batch := events[i:end]
 		batch := events[i:end]
 		var batchInput []lago.EventInput
 		var batchInput []lago.EventInput
 		for i := range batch {
 		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{
 			event := lago.EventInput{
 				TransactionID:          batch[i].TransactionID,
 				TransactionID:          batch[i].TransactionID,
-				ExternalSubscriptionID: externalSubscriptionID,
+				ExternalSubscriptionID: subscriptionID,
 				Code:                   batch[i].EventType,
 				Code:                   batch[i].EventType,
 				Properties:             batch[i].Properties,
 				Properties:             batch[i].Properties,
 			}
 			}
@@ -492,6 +478,43 @@ func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, plan
 	return nil
 	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 {
 func createUsageFromLagoUsage(lagoUsage lago.CustomerUsage) types.Usage {
 	usage := types.Usage{}
 	usage := types.Usage{}
 	usage.FromDatetime = lagoUsage.FromDatetime.Format(time.RFC3339)
 	usage.FromDatetime = lagoUsage.FromDatetime.Format(time.RFC3339)