Selaa lähdekoodia

Wrap up Metronome logic for granting credits

Mauricio Araujo 2 vuotta sitten
vanhempi
sitoutus
6e21c18394

+ 7 - 3
api/server/handlers/billing/credits.go

@@ -2,6 +2,7 @@ package billing
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -18,10 +19,10 @@ const (
 	referralRewardRequirement = 5
 	// defaultRewardAmountUSD is the default amount in USD rewarded to users
 	// who reach the reward requirement
-	defaultRewardAmountUSD = 20
+	defaultRewardAmountCents = 2000
 	// defaultPaidAmountUSD is the amount paid by the user to get the credits
 	// grant, if set to 0 it means they were free
-	defaultPaidAmountUSD = 0
+	defaultPaidAmountCents = 0
 )
 
 // ListCreditsHandler is a handler for getting available credits
@@ -117,7 +118,10 @@ func (c *ClaimReferralRewardHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	}
 
 	if !user.ReferralRewardClaimed && referralCount >= referralRewardRequirement {
-		err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, proj.UsageID, defaultRewardAmountUSD, defaultPaidAmountUSD)
+		// Metronome requires an expiration to be passed in, so we set it to 5 years which in
+		// practice will mean the credits will run out before expiring
+		expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339)
+		err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, proj.UsageID, defaultRewardAmountCents, defaultPaidAmountCents, expiresAt)
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return

+ 22 - 8
api/types/billing_metronome.go

@@ -58,13 +58,13 @@ type EndCustomerPlanRequest struct {
 // 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   GrantAmount `json:"grant_amount"`
-	PaidAmount    PaidAmount  `json:"paid_amount"`
-	Name          string      `json:"name"`
-	ExpiresAt     string      `json:"expires_at"`
-	Priority      int         `json:"priority"`
+	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"`
 }
 
 // ListCreditGrantsRequest is the request to list a user's credit grants. Note that only one of
@@ -138,7 +138,15 @@ type CreditType struct {
 	ID   string `json:"id"`
 }
 
-// GrantAmount represents the amount of credits granted
+// 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"`
@@ -150,6 +158,12 @@ type PaidAmount struct {
 	CreditTypeID uuid.UUID `json:"credit_type_id"`
 }
 
+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 {

+ 1 - 0
api/types/referral.go

@@ -1,5 +1,6 @@
 package types
 
+// Referral is a struct that represents a referral in the Porter API
 type Referral struct {
 	ID uint `json:"id"`
 	// Code is the referral code that is shared with the referred user

+ 42 - 13
internal/billing/metronome.go

@@ -242,7 +242,8 @@ func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uui
 	return response, nil
 }
 
-func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, grantAmount float64, paidAmount float64) (err error) {
+// CreateCreditsGrant will create a new credit grant for the customer with the specified amount
+func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, grantAmount float64, paidAmount float64, expiresAt string) (err error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-credits-grant")
 	defer span.End()
 
@@ -251,29 +252,33 @@ func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid
 	}
 
 	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")
+	}
+
+	// Uniqueness key is used to prevent duplicate grants
+	uniquenessKey := fmt.Sprintf("%s-referral-reward", customerID)
 
 	req := types.CreateCreditsGrantRequest{
 		CustomerID:    customerID,
-		UniquenessKey: "porter-credits",
-		GrantAmount: types.GrantAmount{
-			Amount:     grantAmount,
-			CreditType: types.CreditType{},
+		UniquenessKey: uniquenessKey,
+		GrantAmount: types.GrantAmountID{
+			Amount:       grantAmount,
+			CreditTypeID: creditTypeID,
 		},
 		PaidAmount: types.PaidAmount{
 			Amount:       paidAmount,
-			CreditTypeID: uuid.UUID{},
+			CreditTypeID: creditTypeID,
 		},
 		Name:      "Porter Credits",
-		ExpiresAt: "", // never expires
+		ExpiresAt: expiresAt,
 		Priority:  1,
 	}
 
-	var result struct {
-		Data []types.CreditGrant `json:"data"`
-	}
-
-	_, err = m.do(http.MethodPost, path, req, &result)
-	if err != nil {
+	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")
 	}
 
@@ -397,6 +402,30 @@ func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID u
 	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")
+}
+
 func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)

+ 1 - 0
internal/models/referral.go

@@ -20,6 +20,7 @@ type Referral struct {
 	Status string
 }
 
+// NewReferralCode generates a new referral code
 func NewReferralCode() string {
 	return shortuuid.New()
 }

+ 2 - 1
internal/repository/gorm/referrals.go

@@ -17,7 +17,7 @@ func NewReferralRepository(db *gorm.DB) repository.ReferralRepository {
 	return &ReferralRepository{db}
 }
 
-// CreateInvite creates a new invite
+// CreateReferral creates a new referral in the database
 func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*models.Referral, error) {
 	user := &models.User{}
 
@@ -38,6 +38,7 @@ func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*mode
 	return referral, nil
 }
 
+// GetReferralByCode returns the number of referrals a user has made
 func (repo *ReferralRepository) GetReferralCountByUserID(userID uint) (int, error) {
 	referrals := []models.Referral{}
 	if err := repo.db.Where("user_id = ?", userID).Find(&referrals).Error; err != nil {