Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Referral program #4590

Merged
merged 19 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions api/server/handlers/billing/create.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package billing

import (
"context"
"fmt"
"net/http"
"time"

"github.com/porter-dev/porter/api/server/handlers"
"github.com/porter-dev/porter/api/server/shared"
Expand Down Expand Up @@ -41,6 +43,7 @@ func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
defer span.End()

proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
user, _ := ctx.Value(types.UserScope).(*models.User)

clientSecret, err := c.Config().BillingManager.StripeClient.CreatePaymentMethod(ctx, proj.BillingID)
if err != nil {
Expand All @@ -54,6 +57,16 @@ func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
telemetry.AttributeKV{Key: "customer-id", Value: proj.BillingID},
)

if proj.EnableSandbox {
// Grant a reward to the project that referred this user after linking a payment method
err = c.grantRewardIfReferral(ctx, user.ID)
if err != nil {
err := telemetry.Error(ctx, span, err, "error granting credits reward")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
}

c.WriteResult(w, r, clientSecret)
}

Expand Down Expand Up @@ -104,3 +117,53 @@ func (c *SetDefaultBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ

c.WriteResult(w, r, "")
}

func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referredUserID uint) (err error) {
ctx, span := telemetry.NewSpan(ctx, "grant-referral-reward")
defer span.End()

referral, err := c.Repo().Referral().GetReferralByReferredID(referredUserID)
if err != nil {
return telemetry.Error(ctx, span, err, "failed to find referral by referred id")
}

if referral == nil {
return nil
}

referralCount, err := c.Repo().Referral().CountReferralsByProjectID(referral.ProjectID, models.ReferralStatusCompleted)
if err != nil {
return telemetry.Error(ctx, span, err, "failed to get referral count by referrer id")
}

maxReferralRewards := c.Config().BillingManager.MetronomeClient.MaxReferralRewards
if referralCount >= maxReferralRewards {
return nil
}

referrerProject, err := c.Repo().Project().ReadProject(referral.ProjectID)
if err != nil {
return telemetry.Error(ctx, span, err, "failed to find referrer project")
}

if referral != nil && referral.Status != models.ReferralStatusCompleted {
// Metronome 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)
if err != nil {
return telemetry.Error(ctx, span, err, "failed to grand credits reward")
}

referral.Status = models.ReferralStatusCompleted
_, err = c.Repo().Referral().UpdateReferral(referral)
if err != nil {
return telemetry.Error(ctx, span, err, "error while updating referral")
}
}

return nil
}
3 changes: 3 additions & 0 deletions api/server/handlers/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

if p.Config().ServerConf.EnableSandbox {
step = types.StepCleanUp

// Generate referral code for porter cloud projects
proj.ReferralCode = models.NewReferralCode()
}

// create onboarding flow set to the first step. Read in env var
Expand Down
81 changes: 81 additions & 0 deletions api/server/handlers/project/referrals.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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"
"github.com/porter-dev/porter/api/server/shared/config"
"github.com/porter-dev/porter/api/types"
"github.com/porter-dev/porter/internal/models"
"github.com/porter-dev/porter/internal/telemetry"
)

// GetProjectReferralDetailsHandler is a handler for getting a project's referral code
type GetProjectReferralDetailsHandler struct {
handlers.PorterHandlerWriter
}

// NewGetProjectReferralDetailsHandler returns an instance of GetProjectReferralDetailsHandler
func NewGetProjectReferralDetailsHandler(
config *config.Config,
writer shared.ResultWriter,
) *GetProjectReferralDetailsHandler {
return &GetProjectReferralDetailsHandler{
PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
}
}

func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-get-project-referral-details")
defer span.End()

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: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
)
return
}

if proj.ReferralCode == "" {
telemetry.WithAttributes(span,
telemetry.AttributeKV{Key: "referral-code-exists", Value: false},
)

// Generate referral code for project if not present
proj.ReferralCode = models.NewReferralCode()
_, err := c.Repo().Project().UpdateProject(proj)
if err != nil {
err := telemetry.Error(ctx, span, err, "error updating project")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}
}

referralCount, err := c.Repo().Referral().CountReferralsByProjectID(proj.ID, models.ReferralStatusCompleted)
if err != nil {
err := telemetry.Error(ctx, span, err, "error listing referrals by project id")
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}

referralCodeResponse := struct {
Code string `json:"code"`
ReferralCount int64 `json:"referral_count"`
MaxAllowedRewards int64 `json:"max_allowed_referrals"`
}{
Code: proj.ReferralCode,
ReferralCount: referralCount,
MaxAllowedRewards: c.Config().BillingManager.MetronomeClient.MaxReferralRewards,
}

c.WriteResult(w, r, referralCodeResponse)
}
16 changes: 13 additions & 3 deletions api/server/handlers/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,12 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

// write the user to the db
user, err = u.Repo().User().CreateUser(user)

if err != nil {
u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
}

err = addUserToDefaultProject(u.Config(), user)

if err != nil {
u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
return
Expand All @@ -95,7 +93,20 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// non-fatal send email verification
if !user.EmailVerified {
err = startEmailVerification(u.Config(), w, r, user)
if err != nil {
u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
}
}

// create referral if referred by another user
if request.ReferredBy != "" {
referral := &models.Referral{
Code: request.ReferredBy,
ReferredUserID: user.ID,
Status: models.ReferralStatusSignedUp,
}

_, err = u.Repo().Referral().CreateReferral(referral)
if err != nil {
u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
}
Expand Down Expand Up @@ -146,7 +157,6 @@ func addUserToDefaultProject(config *config.Config, user *models.User) error {
Kind: types.RoleAdmin,
},
})

if err != nil {
return err
}
Expand Down
27 changes: 27 additions & 0 deletions api/server/router/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,33 @@ func getProjectRoutes(
Router: r,
})

// GET /api/projects/{project_id}/referrals/details -> user.NewGetUserReferralDetailsHandler
getReferralDetailsEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Verb: types.APIVerbGet,
Method: types.HTTPVerbGet,
Path: &types.Path{
Parent: basePath,
RelativePath: relPath + "/referrals/details",
},
Scopes: []types.PermissionScope{
types.UserScope,
types.ProjectScope,
},
},
)

getReferralDetailsHandler := project.NewGetProjectReferralDetailsHandler(
config,
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: getReferralDetailsEndpoint,
Handler: getReferralDetailsHandler,
Router: r,
})

// POST /api/projects/{project_id}/billing/usage -> project.NewListCustomerUsageHandler
listCustomerUsageEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Expand Down
60 changes: 35 additions & 25 deletions api/types/billing_metronome.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ type EndCustomerPlanRequest struct {
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 {
Expand All @@ -73,18 +86,6 @@ type ListCreditGrantsResponse struct {
GrantedCredits float64 `json:"granted_credits"`
}

// EmbeddableDashboardRequest requests an embeddable customer dashboard to Metronome
type EmbeddableDashboardRequest struct {
// CustomerID is the id of the customer
CustomerID uuid.UUID `json:"customer_id,omitempty"`
// DashboardType is the type of dashboard to retrieve
DashboardType string `json:"dashboard"`
// Options are optional dashboard specific options
Options []DashboardOption `json:"dashboard_options,omitempty"`
// ColorOverrides is an optional list of colors to override
ColorOverrides []ColorOverride `json:"color_overrides,omitempty"`
}

// ListCustomerUsageRequest is the request to list usage for a customer
type ListCustomerUsageRequest struct {
CustomerID uuid.UUID `json:"customer_id"`
Expand Down Expand Up @@ -138,12 +139,33 @@ 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"`
}

// 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 {
Expand All @@ -166,18 +188,6 @@ type CreditGrant struct {
ExpiresAt string `json:"expires_at"`
}

// DashboardOption are optional dashboard specific options
type DashboardOption struct {
Key string `json:"key"`
Value string `json:"value"`
}

// ColorOverride is an optional list of colors to override
type ColorOverride struct {
Name string `json:"name"`
Value string `json:"value"`
}

// BillingEvent represents a Metronome billing event.
type BillingEvent struct {
CustomerID string `json:"customer_id"`
Expand Down
2 changes: 2 additions & 0 deletions api/types/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type Project struct {
AdvancedInfraEnabled bool `json:"advanced_infra_enabled"`
SandboxEnabled bool `json:"sandbox_enabled"`
AdvancedRbacEnabled bool `json:"advanced_rbac_enabled"`
// ReferralCode is a unique code that can be shared to referr other users to Porter
ReferralCode string `json:"referral_code"`
}

// FeatureFlags is a struct that contains old feature flag representations
Expand Down
12 changes: 12 additions & 0 deletions api/types/referral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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
Code string `json:"referral_code"`
// ReferredUserID is the ID of the user who was referred
ReferredUserID uint `json:"referred_user_id"`
// Status is the status of the referral (pending, signed_up, etc.)
Status string `json:"status"`
}
2 changes: 2 additions & 0 deletions api/types/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type CreateUserRequest struct {
LastName string `json:"last_name" form:"required,max=255"`
CompanyName string `json:"company_name" form:"required,max=255"`
ReferralMethod string `json:"referral_method" form:"max=255"`
// ReferredBy is the referral code of the project from which this user was referred
ReferredBy string `json:"referred_by_code" form:"max=255"`
}

type CreateUserResponse User
Expand Down
Loading
Loading