From 54aa00b6ee6f38b0d270422d7ebc012164f92986 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 12:02:55 -0400 Subject: [PATCH] Wrap up Metronome logic for granting credits --- api/server/handlers/billing/credits.go | 10 +++-- api/types/billing_metronome.go | 30 ++++++++++---- api/types/referral.go | 1 + internal/billing/metronome.go | 55 ++++++++++++++++++++------ internal/models/referral.go | 1 + internal/repository/gorm/referrals.go | 3 +- 6 files changed, 75 insertions(+), 25 deletions(-) diff --git a/api/server/handlers/billing/credits.go b/api/server/handlers/billing/credits.go index 631a5fe8953..2f348e2ea8b 100644 --- a/api/server/handlers/billing/credits.go +++ b/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 diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 5788bb25c23..65f5aad0108 100644 --- a/api/types/billing_metronome.go +++ b/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 { diff --git a/api/types/referral.go b/api/types/referral.go index 5e098ce9c0c..dbdb50b154e 100644 --- a/api/types/referral.go +++ b/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 diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index 0aa12edc803..f4d1c79e64f 100644 --- a/internal/billing/metronome.go +++ b/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) diff --git a/internal/models/referral.go b/internal/models/referral.go index bcb04a9645c..0af91723242 100644 --- a/internal/models/referral.go +++ b/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() } diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index 8726cb946de..5659c81449d 100644 --- a/internal/repository/gorm/referrals.go +++ b/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 {