From a4050f5845e865d93d08ad9739723305cd3052f3 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Sun, 28 Apr 2024 22:16:22 -0400 Subject: [PATCH 01/17] Add skeleton for referrals --- api/server/handlers/user/create.go | 18 +++- api/server/handlers/user/github_callback.go | 6 +- api/server/handlers/user/google_callback.go | 6 +- api/server/handlers/user/referrals.go | 39 +++++++ api/server/router/user.go | 24 +++++ api/types/referral.go | 11 ++ api/types/user.go | 5 + dashboard/src/lib/hooks/useStripe.tsx | 34 ++++++ dashboard/src/main/auth/Register.tsx | 21 +++- .../home/project-settings/ProjectSettings.tsx | 8 ++ .../home/project-settings/ReferralsPage.tsx | 26 +++++ dashboard/src/shared/Context.tsx | 2 +- dashboard/src/shared/api.tsx | 100 +++++++++--------- go.mod | 1 + go.sum | 2 + internal/models/referral.go | 34 ++++++ internal/models/user.go | 6 ++ internal/repository/gorm/migrate.go | 1 + internal/repository/gorm/referrals.go | 39 +++++++ internal/repository/gorm/repository.go | 6 ++ internal/repository/referral.go | 15 +++ internal/repository/repository.go | 1 + 22 files changed, 344 insertions(+), 61 deletions(-) create mode 100644 api/server/handlers/user/referrals.go create mode 100644 api/types/referral.go create mode 100644 dashboard/src/main/home/project-settings/ReferralsPage.tsx create mode 100644 internal/models/referral.go create mode 100644 internal/repository/gorm/referrals.go create mode 100644 internal/repository/referral.go diff --git a/api/server/handlers/user/create.go b/api/server/handlers/user/create.go index 0b1571cce1..012753ab4d 100644 --- a/api/server/handlers/user/create.go +++ b/api/server/handlers/user/create.go @@ -70,16 +70,17 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user.Password = string(hashedPw) + // Generate referral code for user + user.ReferralCode = models.NewReferralCode() + // 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 @@ -95,7 +96,19 @@ 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, + } + + _, err := u.Repo().Referral().CreateReferral(referral) if err != nil { u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } @@ -146,7 +159,6 @@ func addUserToDefaultProject(config *config.Config, user *models.User) error { Kind: types.RoleAdmin, }, }) - if err != nil { return err } diff --git a/api/server/handlers/user/github_callback.go b/api/server/handlers/user/github_callback.go index c6fc15c4ed..2cec4ed5a2 100644 --- a/api/server/handlers/user/github_callback.go +++ b/api/server/handlers/user/github_callback.go @@ -85,7 +85,6 @@ func (p *UserOAuthGithubCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt // non-fatal send email verification if !user.EmailVerified { err = startEmailVerification(p.Config(), w, r, user) - if err != nil { p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } @@ -147,14 +146,15 @@ func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User GithubUserID: githubUser.GetID(), } - user, err = config.Repo.User().CreateUser(user) + // Generate referral code for user + user.ReferralCode = models.NewReferralCode() + user, err = config.Repo.User().CreateUser(user) if err != nil { return nil, err } err = addUserToDefaultProject(config, user) - if err != nil { return nil, err } diff --git a/api/server/handlers/user/google_callback.go b/api/server/handlers/user/google_callback.go index 472837c59a..313ad46b23 100644 --- a/api/server/handlers/user/google_callback.go +++ b/api/server/handlers/user/google_callback.go @@ -88,7 +88,6 @@ func (p *UserOAuthGoogleCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt // non-fatal send email verification if !user.EmailVerified { err = startEmailVerification(p.Config(), w, r, user) - if err != nil { p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } @@ -133,14 +132,15 @@ func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*model GoogleUserID: gInfo.Sub, } - user, err = config.Repo.User().CreateUser(user) + // Generate referral code for user + user.ReferralCode = models.NewReferralCode() + user, err = config.Repo.User().CreateUser(user) if err != nil { return nil, err } err = addUserToDefaultProject(config, user) - if err != nil { return nil, err } diff --git a/api/server/handlers/user/referrals.go b/api/server/handlers/user/referrals.go new file mode 100644 index 0000000000..cb645f31a0 --- /dev/null +++ b/api/server/handlers/user/referrals.go @@ -0,0 +1,39 @@ +package user + +import ( + "net/http" + + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/config" +) + +// ListUserReferralsHandler is a handler for getting a list of user referrals +type ListUserReferralsHandler struct { + handlers.PorterHandlerWriter +} + +// NewListUserReferralsHandler returns an instance of ListUserReferralsHandler +func NewListUserReferralsHandler( + config *config.Config, + writer shared.ResultWriter, +) *ListUserReferralsHandler { + return &ListUserReferralsHandler{ + PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), + } +} + +func (u *ListUserReferralsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // ctx, span := telemetry.NewSpan(r.Context(), "serve-list-user-referrals") + // defer span.End() + + // user, _ := ctx.Value(types.UserScope).(*models.User) + + // referrals, err := u.Repo().Referral().ListReferralsByUserID(user.ID) + // if err != nil { + // u.HandleAPIError(w, r, err) + // return + // } + + u.WriteResult(w, r, "") +} diff --git a/api/server/router/user.go b/api/server/router/user.go index ea90ba5122..6cca75e45a 100644 --- a/api/server/router/user.go +++ b/api/server/router/user.go @@ -472,5 +472,29 @@ func getUserRoutes( Router: r, }) + // GET /api/users/referrals -> user.NewListUserReferralsHandler + listReferralsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: "/users/referrals", + }, + Scopes: []types.PermissionScope{types.UserScope}, + }, + ) + + listReferralsHandler := user.NewListUserReferralsHandler( + config, + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: listReferralsEndpoint, + Handler: listReferralsHandler, + Router: r, + }) + return routes } diff --git a/api/types/referral.go b/api/types/referral.go new file mode 100644 index 0000000000..5e098ce9c0 --- /dev/null +++ b/api/types/referral.go @@ -0,0 +1,11 @@ +package types + +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"` +} diff --git a/api/types/user.go b/api/types/user.go index ca815ed1b0..2af9213ecd 100644 --- a/api/types/user.go +++ b/api/types/user.go @@ -7,6 +7,9 @@ type User struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` CompanyName string `json:"company_name"` + + // ReferralCode is a unique code that can be shared to referr other users to Porter + ReferralCode string `json:"referral_code"` } type CreateUserRequest struct { @@ -16,6 +19,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 user who referred this user + ReferredBy string `json:"referred_by_code" form:"max=255"` } type CreateUserResponse User diff --git a/dashboard/src/lib/hooks/useStripe.tsx b/dashboard/src/lib/hooks/useStripe.tsx index 8d9e96a791..39b3a2d891 100644 --- a/dashboard/src/lib/hooks/useStripe.tsx +++ b/dashboard/src/lib/hooks/useStripe.tsx @@ -367,3 +367,37 @@ export const useCustomerUsage = ( usage: usageReq.data ?? null, }; }; + +export const useReferrals = (): TGetPlan => { + const { currentProject, user } = useContext(Context); + + // Fetch current plan + const planReq = useQuery( + ["getReferrals", user?.id], + async (): Promise => { + if (!currentProject?.billing_enabled) { + return null; + } + + if (!user?.id) { + return null; + } + + try { + const res = await api.getReferrals( + "", + {}, + { user_id: user.id } + ); + + const referrals = PlanValidator.parse(res.data); + return referrals; + } catch (error) { + return null + } + }); + + return { + plan: planReq.data ?? null, + }; +}; \ No newline at end of file diff --git a/dashboard/src/main/auth/Register.tsx b/dashboard/src/main/auth/Register.tsx index dae6c25ddb..c78bc96ff4 100644 --- a/dashboard/src/main/auth/Register.tsx +++ b/dashboard/src/main/auth/Register.tsx @@ -35,6 +35,9 @@ const Register: React.FC = ({ authenticate }) => { const [lastName, setLastName] = useState(""); const [lastNameError, setLastNameError] = useState(false); const [companyName, setCompanyName] = useState(""); + const [referralCode, setReferralCode] = useState(""); + const [referralCodeError, setReferralCodeError] = useState(false); + const [companyNameError, setCompanyNameError] = useState(false); const [email, setEmail] = useState(""); const [emailError, setEmailError] = useState(false); @@ -118,6 +121,7 @@ const Register: React.FC = ({ authenticate }) => { chosenReferralOption === "Other" ? `Other: ${referralOtherText}` : chosenReferralOption, + referred_by_code: referralCode, }, {} ) @@ -178,7 +182,7 @@ const Register: React.FC = ({ authenticate }) => { if (res?.data?.redirect) { window.location.href = res.data.redirect; } else { - setUser(res?.data?.id, res?.data?.email); + setUser(res?.data?.id); authenticate(); try { @@ -400,6 +404,21 @@ const Register: React.FC = ({ authenticate }) => { setValue={setChosenReferralOption} value={chosenReferralOption} /> + + { + setReferralCode(x); + setReferralCodeError(false); + }} + width="100%" + height="40px" + error={referralCodeError && ""} + /> + + {chosenReferralOption === "Other" && ( <> diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index 2f8dd9b9c7..58678ade02 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -26,6 +26,7 @@ import settingsGrad from "assets/settings-grad.svg"; import DashboardHeader from "../cluster-dashboard/DashboardHeader"; import APITokensSection from "./APITokensSection"; import BillingPage from "./BillingPage"; +import ReferralsPage from "./ReferralsPage"; import InvitePage from "./InviteList"; import Metadata from "./Metadata"; import ProjectDeleteConsent from "./ProjectDeleteConsent"; @@ -95,6 +96,11 @@ function ProjectSettings(props: any) { }); } + tabOpts.push({ + value: "referrals", + label: "Referrals", + }); + tabOpts.push({ value: "additional-settings", label: "Additional settings", @@ -172,6 +178,8 @@ function ProjectSettings(props: any) { return ; } else if (currentTab === "billing") { return ; + } else if (currentTab === "referrals") { + return } else { return ( <> diff --git a/dashboard/src/main/home/project-settings/ReferralsPage.tsx b/dashboard/src/main/home/project-settings/ReferralsPage.tsx new file mode 100644 index 0000000000..bdd26b54d2 --- /dev/null +++ b/dashboard/src/main/home/project-settings/ReferralsPage.tsx @@ -0,0 +1,26 @@ +import React, { useContext } from "react"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { Context } from "shared/Context"; +import Fieldset from "components/porter/Fieldset"; + +function ReferralsPage(): JSX.Element { + + + return ( + <> + Referrals + + + Refer people to Porter to earn credits. + + + + Your referral code is {user?.referralCode} + + + + ) +} + +export default ReferralsPage; diff --git a/dashboard/src/shared/Context.tsx b/dashboard/src/shared/Context.tsx index e524e0d10a..bf41027336 100644 --- a/dashboard/src/shared/Context.tsx +++ b/dashboard/src/shared/Context.tsx @@ -139,7 +139,7 @@ class ContextProvider extends Component { user: null, setUser: (userId: number, email: string) => { this.setState({ - user: { userId, email, isPorterUser: email?.endsWith("@porter.run") }, + user: { userId, email, isPorterUser: email?.endsWith("@porter.run"), referralCode: referralCode }, }); if (window.intercomSettings) { window.intercomSettings["Porter User ID"] = userId; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index b0e50afe44..ceaaba1171 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -386,9 +386,8 @@ const getFeedEvents = baseApi< } >("GET", (pathParams) => { const { project_id, cluster_id, stack_name, page } = pathParams; - return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${ - page || 1 - }`; + return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1 + }`; }); const createEnvironment = baseApi< @@ -876,11 +875,9 @@ const detectBuildpack = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`; }); const detectGitlabBuildpack = baseApi< @@ -911,11 +908,9 @@ const getBranchContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/contents`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/contents`; }); const getProcfileContents = baseApi< @@ -931,11 +926,9 @@ const getProcfileContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/procfile`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/procfile`; }); const getPorterYamlContents = baseApi< @@ -951,11 +944,9 @@ const getPorterYamlContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/porteryaml`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/porteryaml`; }); const parsePorterYaml = baseApi< @@ -1015,32 +1006,30 @@ const getBranchHead = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/head`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/head`; }); const createApp = baseApi< | { - name: string; - deployment_target_id: string; - type: "github"; - git_repo_id: number; - git_branch: string; - git_repo_name: string; - porter_yaml_path: string; - } + name: string; + deployment_target_id: string; + type: "github"; + git_repo_id: number; + git_branch: string; + git_repo_name: string; + porter_yaml_path: string; + } | { - name: string; - deployment_target_id: string; - type: "docker-registry"; - image: { - repository: string; - tag: string; - }; - }, + name: string; + deployment_target_id: string; + type: "docker-registry"; + image: { + repository: string; + tag: string; + }; + }, { project_id: number; cluster_id: number; @@ -2167,6 +2156,7 @@ const registerUser = baseApi<{ last_name: string; company_name: string; referral_method?: string; + referred_by_code?: string; }>("POST", "/api/users"); const rollbackChart = baseApi< @@ -2308,11 +2298,9 @@ const getEnvGroup = baseApi< version?: number; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.id}/clusters/${ - pathParams.cluster_id - }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${ - pathParams.version ? "&version=" + pathParams.version : "" - }`; + return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id + }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : "" + }`; }); const getConfigMap = baseApi< @@ -3589,7 +3577,18 @@ const deletePaymentMethod = baseApi< `/api/projects/${project_id}/billing/payment_method/${payment_method_id}` ); -const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`); +const getReferrals = baseApi< + {}, + { + user_id?: number; + } +>( + "GET", + ({ user_id }) => + `/api/users/${user_id}/referrals` +); + +const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`); const createSecretAndOpenGitHubPullRequest = baseApi< { @@ -3982,6 +3981,7 @@ export default { addPaymentMethod, setDefaultPaymentMethod, deletePaymentMethod, + getReferrals, // STATUS getGithubStatus, diff --git a/go.mod b/go.mod index 4b720a31ba..9775cd4cb7 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/honeycombio/otel-config-go v1.11.0 github.com/launchdarkly/go-sdk-common/v3 v3.0.1 github.com/launchdarkly/go-server-sdk/v6 v6.1.0 + github.com/lithammer/shortuuid/v4 v4.0.0 github.com/matryer/is v1.4.0 github.com/nats-io/nats.go v1.24.0 github.com/open-policy-agent/opa v0.44.0 diff --git a/go.sum b/go.sum index 68a0c415cd..739a537b1e 100644 --- a/go.sum +++ b/go.sum @@ -1249,6 +1249,8 @@ github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= +github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c= +github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y= github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= diff --git a/internal/models/referral.go b/internal/models/referral.go new file mode 100644 index 0000000000..bcb04a9645 --- /dev/null +++ b/internal/models/referral.go @@ -0,0 +1,34 @@ +package models + +import ( + "github.com/lithammer/shortuuid/v4" + "github.com/porter-dev/porter/api/types" + "gorm.io/gorm" +) + +// Referral type that extends gorm.Model +type Referral struct { + gorm.Model + + // Code is the referral code that is shared with the referred user + Code string + // UserID is the ID of the user who made the referral + UserID uint + // ReferredUserID is the ID of the user who was referred + ReferredUserID uint + // Status is the status of the referral (pending, signed_up, etc.) + Status string +} + +func NewReferralCode() string { + return shortuuid.New() +} + +// ToReferralType generates an external types.Referral to be shared over REST +func (r *Referral) ToReferralType() *types.Referral { + return &types.Referral{ + ID: r.ID, + ReferredUserID: r.ReferredUserID, + Status: r.Status, + } +} diff --git a/internal/models/user.go b/internal/models/user.go index 6d98c0faa8..98f2bb3ba4 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,6 +23,11 @@ type User struct { // The github user id used for login (optional) GithubUserID int64 GoogleUserID string + + // ReferralCode is a unique code that can be shared to referr other users to Porter + ReferralCode string + + Referrals []Referral `json:"referrals"` } // ToUserType generates an external types.User to be shared over REST @@ -34,5 +39,6 @@ func (u *User) ToUserType() *types.User { FirstName: u.FirstName, LastName: u.LastName, CompanyName: u.CompanyName, + ReferralCode: u.ReferralCode, } } diff --git a/internal/repository/gorm/migrate.go b/internal/repository/gorm/migrate.go index 5034caa71e..cab4abef8a 100644 --- a/internal/repository/gorm/migrate.go +++ b/internal/repository/gorm/migrate.go @@ -88,5 +88,6 @@ func AutoMigrate(db *gorm.DB, debug bool) error { &models.Ipam{}, &models.AppEventWebhooks{}, &models.ClusterHealthReport{}, + &models.Referral{}, ) } diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go new file mode 100644 index 0000000000..7478422863 --- /dev/null +++ b/internal/repository/gorm/referrals.go @@ -0,0 +1,39 @@ +package gorm + +import ( + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/repository" + "gorm.io/gorm" +) + +// ReferralRepository uses gorm.DB for querying the database +type ReferralRepository struct { + db *gorm.DB +} + +// NewReferralRepository returns a ReferralRepository which uses +// gorm.DB for querying the database +func NewReferralRepository(db *gorm.DB) repository.ReferralRepository { + return &ReferralRepository{db} +} + +// CreateInvite creates a new invite +func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*models.Referral, error) { + user := &models.User{} + + if err := repo.db.Where("referral_code = ?", referral.Code).First(&user).Error; err != nil { + return nil, err + } + + assoc := repo.db.Model(&user).Association("Referrals") + + if assoc.Error != nil { + return nil, assoc.Error + } + + if err := assoc.Append(referral); err != nil { + return nil, err + } + + return referral, nil +} diff --git a/internal/repository/gorm/repository.go b/internal/repository/gorm/repository.go index 555d14d4b7..bce91a1249 100644 --- a/internal/repository/gorm/repository.go +++ b/internal/repository/gorm/repository.go @@ -62,6 +62,7 @@ type GormRepository struct { datastore repository.DatastoreRepository appInstance repository.AppInstanceRepository ipam repository.IpamRepository + referral repository.ReferralRepository } func (t *GormRepository) User() repository.UserRepository { @@ -293,6 +294,10 @@ func (t *GormRepository) Ipam() repository.IpamRepository { return t.ipam } +func (t *GormRepository) Referral() repository.ReferralRepository { + return t.referral +} + // NewRepository returns a Repository which persists users in memory // and accepts a parameter that can trigger read/write errors func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository { @@ -352,5 +357,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden appInstance: NewAppInstanceRepository(db), ipam: NewIpamRepository(db), appEventWebhook: NewAppEventWebhookRepository(db), + referral: NewReferralRepository(db), } } diff --git a/internal/repository/referral.go b/internal/repository/referral.go new file mode 100644 index 0000000000..43b6ef109f --- /dev/null +++ b/internal/repository/referral.go @@ -0,0 +1,15 @@ +package repository + +import ( + "github.com/porter-dev/porter/internal/models" +) + +// ReferralRepository represents the set of queries on the Referral model +type ReferralRepository interface { + CreateReferral(referral *models.Referral) (*models.Referral, error) + // ReadReferral(referralID uint) (*models.Referral, error) + // ReadReferralByUserID(userID, referralID string) (*models.Referral, error) + // ListReferralsByUserID(userID uint) ([]*models.Referral, error) + // UpdateReferral(referral *models.Referral) (*models.Referral, error) + // DeleteReferral(referralID uint) error +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 2bb2df1f98..a803a41f3b 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -55,4 +55,5 @@ type Repository interface { GithubWebhook() GithubWebhookRepository Datastore() DatastoreRepository AppInstance() AppInstanceRepository + Referral() ReferralRepository } From 18315fe5ef6a0fb8290307954dc725c929396f5d Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Mon, 29 Apr 2024 13:54:54 -0400 Subject: [PATCH 02/17] Finish logic for creating referrals and granting rewards --- api/server/handlers/billing/credits.go | 128 ++++++++++++++++++ api/server/handlers/billing/plan.go | 46 ------- api/server/handlers/user/create.go | 2 +- api/server/handlers/user/referrals.go | 60 ++++++-- api/server/router/project.go | 27 ++++ api/server/router/user.go | 28 +++- api/types/billing_metronome.go | 42 +++--- api/types/user.go | 3 + dashboard/src/lib/billing/types.tsx | 10 ++ dashboard/src/lib/hooks/useStripe.tsx | 99 ++++++++++++-- .../home/project-settings/ReferralsPage.tsx | 61 ++++++++- dashboard/src/shared/Context.tsx | 2 +- dashboard/src/shared/api.tsx | 28 +++- internal/billing/metronome.go | 38 ++++++ internal/models/user.go | 18 ++- internal/repository/gorm/referrals.go | 8 ++ internal/repository/referral.go | 1 + 17 files changed, 486 insertions(+), 115 deletions(-) create mode 100644 api/server/handlers/billing/credits.go diff --git a/api/server/handlers/billing/credits.go b/api/server/handlers/billing/credits.go new file mode 100644 index 0000000000..9acdcfebb5 --- /dev/null +++ b/api/server/handlers/billing/credits.go @@ -0,0 +1,128 @@ +package billing + +import ( + "net/http" + + "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" +) + +const ( + // referralRewardRequirement is the number of referred users required to + // be granted a credits reward + referralRewardRequirement = 5 + // defaultRewardAmountUSD is the default amount in USD rewarded to users + // who reach the reward requirement + defaultRewardAmountUSD = 20 + // defaultPaidAmountUSD is the amount paid by the user to get the credits + // grant, if set to 0 it means they were free + defaultPaidAmountUSD = 0 +) + +// ListCreditsHandler is a handler for getting available credits +type ListCreditsHandler struct { + handlers.PorterHandlerWriter +} + +// NewListCreditsHandler will create a new ListCreditsHandler +func NewListCreditsHandler( + config *config.Config, + writer shared.ResultWriter, +) *ListCreditsHandler { + return &ListCreditsHandler{ + PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), + } +} + +func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-credits") + defer span.End() + + proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + + if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + 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 + } + + credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error listing credits") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, + telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, + ) + + c.WriteResult(w, r, credits) +} + +// ClaimReferralRewardHandler is a handler for granting credits +type ClaimReferralRewardHandler struct { + handlers.PorterHandlerWriter +} + +// NewClaimReferralReward will create a new GrantCreditsHandler +func NewClaimReferralReward( + config *config.Config, + writer shared.ResultWriter, +) *ClaimReferralRewardHandler { + return &ClaimReferralRewardHandler{ + PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), + } +} + +func (c *ClaimReferralRewardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-claim-credits-reward") + defer span.End() + + proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + user, _ := ctx.Value(types.UserScope).(*models.User) + + if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + 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 + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, + telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, + telemetry.AttributeKV{Key: "referral-code", Value: user.ReferralCode}, + telemetry.AttributeKV{Key: "referral-reward-received", Value: user.ReferralRewardClaimed}, + ) + + if !user.ReferralRewardClaimed { + err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, proj.UsageID, defaultRewardAmountUSD, defaultPaidAmountUSD) + if err != nil { + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + user.ReferralRewardClaimed = true + _, err = c.Repo().User().UpdateUser(user) + if err != nil { + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + } + + c.WriteResult(w, r, "") +} diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 1d5f0ed70d..1b0cc2646a 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -58,52 +58,6 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.WriteResult(w, r, plan) } -// ListCreditsHandler is a handler for getting available credits -type ListCreditsHandler struct { - handlers.PorterHandlerWriter -} - -// NewListCreditsHandler will create a new ListCreditsHandler -func NewListCreditsHandler( - config *config.Config, - writer shared.ResultWriter, -) *ListCreditsHandler { - return &ListCreditsHandler{ - PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), - } -} - -func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-list-credits") - defer span.End() - - proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { - 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 - } - - credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID) - if err != nil { - err := telemetry.Error(ctx, span, err, "error listing credits") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - ) - - c.WriteResult(w, r, credits) -} - // ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours. type ListCustomerUsageHandler struct { handlers.PorterHandlerReadWriter diff --git a/api/server/handlers/user/create.go b/api/server/handlers/user/create.go index 012753ab4d..5d1cbe829c 100644 --- a/api/server/handlers/user/create.go +++ b/api/server/handlers/user/create.go @@ -108,7 +108,7 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ReferredUserID: user.ID, } - _, err := u.Repo().Referral().CreateReferral(referral) + _, err = u.Repo().Referral().CreateReferral(referral) if err != nil { u.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } diff --git a/api/server/handlers/user/referrals.go b/api/server/handlers/user/referrals.go index cb645f31a0..1be2df0a69 100644 --- a/api/server/handlers/user/referrals.go +++ b/api/server/handlers/user/referrals.go @@ -5,7 +5,11 @@ import ( "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" ) // ListUserReferralsHandler is a handler for getting a list of user referrals @@ -24,16 +28,54 @@ func NewListUserReferralsHandler( } func (u *ListUserReferralsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // ctx, span := telemetry.NewSpan(r.Context(), "serve-list-user-referrals") - // defer span.End() + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-user-referrals") + defer span.End() - // user, _ := ctx.Value(types.UserScope).(*models.User) + user, _ := ctx.Value(types.UserScope).(*models.User) - // referrals, err := u.Repo().Referral().ListReferralsByUserID(user.ID) - // if err != nil { - // u.HandleAPIError(w, r, err) - // return - // } + referralCount, err := u.Repo().Referral().GetReferralCountByUserID(user.ID) + if err != nil { + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + referralResponse := struct { + ReferralCount int `json:"count"` + }{ + ReferralCount: referralCount, + } + + u.WriteResult(w, r, referralResponse) +} + +// GetUserReferralDetailsHandler is a handler for getting a user's referral code +type GetUserReferralDetailsHandler struct { + handlers.PorterHandlerWriter +} + +// NewGetUserReferralDetailsHandler returns an instance of GetUserReferralCodeHandler +func NewGetUserReferralDetailsHandler( + config *config.Config, + writer shared.ResultWriter, +) *GetUserReferralDetailsHandler { + return &GetUserReferralDetailsHandler{ + PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), + } +} + +func (u *GetUserReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-get-user-referral-details") + defer span.End() + + user, _ := ctx.Value(types.UserScope).(*models.User) + + referralCodeResponse := struct { + Code string `json:"code"` + RewardClaimed bool `json:"reward_claimed"` + }{ + Code: user.ReferralCode, + RewardClaimed: user.ReferralRewardClaimed, + } - u.WriteResult(w, r, "") + u.WriteResult(w, r, referralCodeResponse) } diff --git a/api/server/router/project.go b/api/server/router/project.go index d5e9e851fb..6b78f68d51 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -397,6 +397,33 @@ func getProjectRoutes( Router: r, }) + // GET /api/projects/{project_id}/billing/credits/claim_referral -> project.NewGetCreditsHandler + claimReferralRewardEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbCreate, + Method: types.HTTPVerbPost, + Path: &types.Path{ + Parent: basePath, + RelativePath: relPath + "/billing/credits/claim_referral", + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + claimReferralRewardHandler := billing.NewClaimReferralReward( + config, + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: claimReferralRewardEndpoint, + Handler: claimReferralRewardHandler, + Router: r, + }) + // POST /api/projects/{project_id}/billing/usage -> project.NewListCustomerUsageHandler listCustomerUsageEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/api/server/router/user.go b/api/server/router/user.go index 6cca75e45a..4d8f736ccc 100644 --- a/api/server/router/user.go +++ b/api/server/router/user.go @@ -472,14 +472,14 @@ func getUserRoutes( Router: r, }) - // GET /api/users/referrals -> user.NewListUserReferralsHandler + // GET /api/referrals -> user.NewListUserReferralsHandler listReferralsEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ Verb: types.APIVerbGet, Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: "/users/referrals", + RelativePath: "/referrals", }, Scopes: []types.PermissionScope{types.UserScope}, }, @@ -496,5 +496,29 @@ func getUserRoutes( Router: r, }) + // GET /api/referrals/details -> user.NewGetUserReferralDetailsHandler + getReferralDetailsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: "/referrals/details", + }, + Scopes: []types.PermissionScope{types.UserScope}, + }, + ) + + getReferralDetailsHandler := user.NewGetUserReferralDetailsHandler( + config, + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: getReferralDetailsEndpoint, + Handler: getReferralDetailsHandler, + Router: r, + }) + return routes } diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 502afa244a..5788bb25c2 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -55,6 +55,18 @@ 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 GrantAmount `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 // CreditTypeIDs, CustomerIDs, or CreditGrantIDs must be specified. type ListCreditGrantsRequest struct { @@ -73,18 +85,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"` @@ -144,6 +144,12 @@ type GrantAmount struct { 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"` +} + // Balance represents the effective balance of the grant as of the end of the customer's // current billing period. type Balance struct { @@ -166,18 +172,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"` diff --git a/api/types/user.go b/api/types/user.go index 2af9213ecd..db103e1e77 100644 --- a/api/types/user.go +++ b/api/types/user.go @@ -10,6 +10,9 @@ type User struct { // ReferralCode is a unique code that can be shared to referr other users to Porter ReferralCode string `json:"referral_code"` + // ReferralRewardClaimed indicates if the user has already received a credits reward + // for referring users + ReferralRewardClaimed bool `json:"referral_reward_received"` } type CreateUserRequest struct { diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 81c7a85fc2..732afc5ac4 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -50,3 +50,13 @@ export const CreditGrantsValidator = z.object({ }); export const ClientSecretResponse = z.string(); + +export type ReferralDetails = z.infer; +export const ReferralDetailsValidator = z.object({ + code: z.string(), + reward_claimed: z.boolean(), +}).nullable(); + +export const ReferralsValidator = z.object({ + count: z.number(), +}).nullable(); diff --git a/dashboard/src/lib/hooks/useStripe.tsx b/dashboard/src/lib/hooks/useStripe.tsx index 39b3a2d891..c83bbdad56 100644 --- a/dashboard/src/lib/hooks/useStripe.tsx +++ b/dashboard/src/lib/hooks/useStripe.tsx @@ -1,5 +1,5 @@ import { useContext, useState } from "react"; -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useQuery, useMutation, type UseQueryResult } from "@tanstack/react-query"; import { z } from "zod"; import { @@ -13,6 +13,9 @@ import { type PaymentMethod, type PaymentMethodList, type UsageList, + ReferralsValidator, + ReferralDetailsValidator, + ReferralDetails } from "lib/billing/types"; import api from "shared/api"; @@ -60,6 +63,14 @@ type TGetUsage = { usage: UsageList | null; }; +type TGetReferralDetails = { + referralDetails: ReferralDetails +}; + +type TGetReferrals = { + referralsCount: number | null; +}; + export const usePaymentMethods = (): TUsePaymentMethod => { const { currentProject } = useContext(Context); @@ -368,36 +379,96 @@ export const useCustomerUsage = ( }; }; -export const useReferrals = (): TGetPlan => { - const { currentProject, user } = useContext(Context); +export const useReferrals = (): TGetReferrals => { + const { currentProject } = useContext(Context); - // Fetch current plan - const planReq = useQuery( - ["getReferrals", user?.id], - async (): Promise => { - if (!currentProject?.billing_enabled) { + // Fetch referrals count + const referralsReq = useQuery( + ["getReferrals", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { return null; } - if (!user?.id) { + try { + const res = await api.getReferrals( + "", + {}, + {} + ); + + const referrals = ReferralsValidator.parse(res.data); + return referrals?.count ?? null; + } catch (error) { + return null + } + }); + + return { + referralsCount: referralsReq.data ?? null, + }; +}; + +export const useReferralDetails = (): TGetReferralDetails => { + const { currentProject } = useContext(Context); + + // Fetch user's referral code + const referralsReq = useQuery( + ["getReferralDetails", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id || currentProject.id === -1) { return null; } try { - const res = await api.getReferrals( + const res = await api.getReferralDetails( "", {}, - { user_id: user.id } + {} ); - const referrals = PlanValidator.parse(res.data); - return referrals; + const referraldetails = ReferralDetailsValidator.parse(res.data); + return referraldetails; } catch (error) { return null } }); return { - plan: planReq.data ?? null, + referralDetails: referralsReq.data ?? null, }; +}; + +export const useClaimReferralReward = (): (() => void) => { + const { currentProject } = useContext(Context); + + // Apply credits reward to this project + const referralsReq = useMutation( + ["claimReferralReward", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return; + } + + if (!currentProject?.id || currentProject.id === -1) { + return; + } + + try { + await api.claimReferralReward( + "", + {}, + { project_id: currentProject?.id } + ); + } catch (error) { + return; + } + }); + + // Return a function that can be called to execute the mutation + return () => referralsReq.mutate(); }; \ No newline at end of file diff --git a/dashboard/src/main/home/project-settings/ReferralsPage.tsx b/dashboard/src/main/home/project-settings/ReferralsPage.tsx index bdd26b54d2..4bc811740d 100644 --- a/dashboard/src/main/home/project-settings/ReferralsPage.tsx +++ b/dashboard/src/main/home/project-settings/ReferralsPage.tsx @@ -1,11 +1,62 @@ -import React, { useContext } from "react"; +import React from "react"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; -import { Context } from "shared/Context"; -import Fieldset from "components/porter/Fieldset"; +import { useClaimReferralReward, useReferralDetails, useReferrals } from "lib/hooks/useStripe"; +import Button from "components/porter/Button"; function ReferralsPage(): JSX.Element { + const referralRewardRequirement = 5; + const { referralDetails } = useReferralDetails(); + const { referralsCount } = useReferrals(); + const claimReferralReward = useClaimReferralReward(); + const eligibleForReward = (): boolean => { + if (referralsCount === null) { + return false; + } + + return referralsCount >= referralRewardRequirement; + } + + const claimReward = (): void => { + claimReferralReward(); + } + + const displayReferral = (): JSX.Element => { + if (referralDetails === null || referralsCount === null) { + return <> + } + + if (!eligibleForReward()) { + return ( + <> + + Refer {referralRewardRequirement - referralsCount} more people to earn a reward. + + + + ) + } + + if (referralDetails?.reward_claimed) { + return ( + <> + + You have already claimed a reward for referring people to Porter. + + + + ) + } + + return ( + <> + You are elegible for claiming a reward on this project. + + + + ) + } return ( <> @@ -15,9 +66,7 @@ function ReferralsPage(): JSX.Element { Refer people to Porter to earn credits. - - Your referral code is {user?.referralCode} - + {displayReferral()} ) diff --git a/dashboard/src/shared/Context.tsx b/dashboard/src/shared/Context.tsx index bf41027336..e524e0d10a 100644 --- a/dashboard/src/shared/Context.tsx +++ b/dashboard/src/shared/Context.tsx @@ -139,7 +139,7 @@ class ContextProvider extends Component { user: null, setUser: (userId: number, email: string) => { this.setState({ - user: { userId, email, isPorterUser: email?.endsWith("@porter.run"), referralCode: referralCode }, + user: { userId, email, isPorterUser: email?.endsWith("@porter.run") }, }); if (window.intercomSettings) { window.intercomSettings["Porter User ID"] = userId; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index ceaaba1171..58324018ce 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -3579,15 +3579,31 @@ const deletePaymentMethod = baseApi< const getReferrals = baseApi< {}, - { - user_id?: number; - } + {} +>( + "GET", + () => + `/api/referrals` +); + +const getReferralDetails = baseApi< + {}, + {} >( "GET", - ({ user_id }) => - `/api/users/${user_id}/referrals` + () => + `/api/referrals/details` ); +const claimReferralReward = baseApi< + {}, + { + project_id?: number; + } +>( + "POST", + ({ project_id }) => `/api/projects/${project_id}/billing/credits/claim_referral` +); const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`); const createSecretAndOpenGitHubPullRequest = baseApi< @@ -3982,6 +3998,8 @@ export default { setDefaultPaymentMethod, deletePaymentMethod, getReferrals, + getReferralDetails, + claimReferralReward, // STATUS getGithubStatus, diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index 0e9a786332..0aa12edc80 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -242,6 +242,44 @@ 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) { + ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") + defer span.End() + + if customerID == uuid.Nil { + return telemetry.Error(ctx, span, err, "customer id empty") + } + + path := "credits/createGrant" + + req := types.CreateCreditsGrantRequest{ + CustomerID: customerID, + UniquenessKey: "porter-credits", + GrantAmount: types.GrantAmount{ + Amount: grantAmount, + CreditType: types.CreditType{}, + }, + PaidAmount: types.PaidAmount{ + Amount: paidAmount, + CreditTypeID: uuid.UUID{}, + }, + Name: "Porter Credits", + ExpiresAt: "", // never expires + Priority: 1, + } + + var result struct { + Data []types.CreditGrant `json:"data"` + } + + _, err = m.do(http.MethodPost, path, req, &result) + if err != nil { + return telemetry.Error(ctx, span, err, "failed to create credits grant") + } + + return nil +} + // ListCustomerUsage will return the aggregated usage for a customer func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) { ctx, span := telemetry.NewSpan(ctx, "list-customer-usage") diff --git a/internal/models/user.go b/internal/models/user.go index 98f2bb3ba4..4db859d3c2 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -26,6 +26,9 @@ type User struct { // ReferralCode is a unique code that can be shared to referr other users to Porter ReferralCode string + // ReferralRewardClaimed indicates if the user has already received a credits reward + // for referring users + ReferralRewardClaimed bool Referrals []Referral `json:"referrals"` } @@ -33,12 +36,13 @@ type User struct { // ToUserType generates an external types.User to be shared over REST func (u *User) ToUserType() *types.User { return &types.User{ - ID: u.ID, - Email: u.Email, - EmailVerified: u.EmailVerified, - FirstName: u.FirstName, - LastName: u.LastName, - CompanyName: u.CompanyName, - ReferralCode: u.ReferralCode, + ID: u.ID, + Email: u.Email, + EmailVerified: u.EmailVerified, + FirstName: u.FirstName, + LastName: u.LastName, + CompanyName: u.CompanyName, + ReferralCode: u.ReferralCode, + ReferralRewardClaimed: u.ReferralRewardClaimed, } } diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index 7478422863..8726cb946d 100644 --- a/internal/repository/gorm/referrals.go +++ b/internal/repository/gorm/referrals.go @@ -37,3 +37,11 @@ func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*mode return referral, nil } + +func (repo *ReferralRepository) GetReferralCountByUserID(userID uint) (int, error) { + referrals := []models.Referral{} + if err := repo.db.Where("user_id = ?", userID).Find(&referrals).Error; err != nil { + return 0, err + } + return len(referrals), nil +} diff --git a/internal/repository/referral.go b/internal/repository/referral.go index 43b6ef109f..1dfc2395b7 100644 --- a/internal/repository/referral.go +++ b/internal/repository/referral.go @@ -7,6 +7,7 @@ import ( // ReferralRepository represents the set of queries on the Referral model type ReferralRepository interface { CreateReferral(referral *models.Referral) (*models.Referral, error) + GetReferralCountByUserID(userID uint) (int, error) // ReadReferral(referralID uint) (*models.Referral, error) // ReadReferralByUserID(userID, referralID string) (*models.Referral, error) // ListReferralsByUserID(userID uint) ([]*models.Referral, error) From a34334f69c9f3bc52261ddf1b67149991ab7c5d3 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Mon, 29 Apr 2024 14:09:50 -0400 Subject: [PATCH 03/17] Check for reward eligibility on backend --- api/server/handlers/billing/credits.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/server/handlers/billing/credits.go b/api/server/handlers/billing/credits.go index 9acdcfebb5..631a5fe895 100644 --- a/api/server/handlers/billing/credits.go +++ b/api/server/handlers/billing/credits.go @@ -109,7 +109,14 @@ func (c *ClaimReferralRewardHandler) ServeHTTP(w http.ResponseWriter, r *http.Re telemetry.AttributeKV{Key: "referral-reward-received", Value: user.ReferralRewardClaimed}, ) - if !user.ReferralRewardClaimed { + // Check if the user is eligible for the referral reward + referralCount, err := c.Repo().Referral().GetReferralCountByUserID(user.ID) + if err != nil { + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + if !user.ReferralRewardClaimed && referralCount >= referralRewardRequirement { err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, proj.UsageID, defaultRewardAmountUSD, defaultPaidAmountUSD) if err != nil { c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) From 28d58d3b572d10bb15c518ab8cf3686d8db1bd9b Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Mon, 29 Apr 2024 15:48:37 -0400 Subject: [PATCH 04/17] Remove extra comments --- internal/repository/referral.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/repository/referral.go b/internal/repository/referral.go index 1dfc2395b7..f25429c972 100644 --- a/internal/repository/referral.go +++ b/internal/repository/referral.go @@ -8,9 +8,4 @@ import ( type ReferralRepository interface { CreateReferral(referral *models.Referral) (*models.Referral, error) GetReferralCountByUserID(userID uint) (int, error) - // ReadReferral(referralID uint) (*models.Referral, error) - // ReadReferralByUserID(userID, referralID string) (*models.Referral, error) - // ListReferralsByUserID(userID uint) ([]*models.Referral, error) - // UpdateReferral(referral *models.Referral) (*models.Referral, error) - // DeleteReferral(referralID uint) error } From 89234a93c8a9d148b75692744f0a49718143b2a0 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 10:17:27 -0400 Subject: [PATCH 05/17] Add and fix tests --- api/server/handlers/user/create_test.go | 61 ++++++++++++++++++++++++- internal/repository/test/referrral.go | 24 ++++++++++ internal/repository/test/repository.go | 7 +++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 internal/repository/test/referrral.go diff --git a/api/server/handlers/user/create_test.go b/api/server/handlers/user/create_test.go index b130606f57..5f724ab044 100644 --- a/api/server/handlers/user/create_test.go +++ b/api/server/handlers/user/create_test.go @@ -1,6 +1,7 @@ package user_test import ( + "encoding/json" "net/http" "testing" @@ -9,6 +10,7 @@ import ( "github.com/porter-dev/porter/api/server/shared/apitest" "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/internal/repository/test" + "github.com/stretchr/testify/assert" ) func TestCreateUserSuccessful(t *testing.T) { @@ -35,7 +37,17 @@ func TestCreateUserSuccessful(t *testing.T) { handler.ServeHTTP(rr, req) - expUser := &types.CreateUserResponse{ + // Use a struct that is the same as types.User but without the + // referral fields. This is because the referral code is randomly + // generated and is tested separately. + expUser := &struct { + ID uint `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + CompanyName string `json:"company_name"` + }{ ID: 1, FirstName: "Mister", LastName: "Porter", @@ -44,7 +56,14 @@ func TestCreateUserSuccessful(t *testing.T) { EmailVerified: false, } - gotUser := &types.CreateUserResponse{} + gotUser := &struct { + ID uint `json:"id"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + CompanyName string `json:"company_name"` + }{} apitest.AssertResponseExpected(t, rr, expUser, gotUser) } @@ -191,3 +210,41 @@ func TestFailingCreateSessionMethod(t *testing.T) { apitest.AssertResponseInternalServerError(t, rr) } + +func TestCreateUserReferralCode(t *testing.T) { + req, rr := apitest.GetRequestAndRecorder( + t, + string(types.HTTPVerbPost), + "/api/users", + &types.CreateUserRequest{ + FirstName: "Mister", + LastName: "Porter", + CompanyName: "Porter Technologies, Inc.", + Email: "mrp@porter.run", + Password: "somepassword", + }, + ) + + config := apitest.LoadConfig(t) + + handler := user.NewUserCreateHandler( + config, + shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter), + shared.NewDefaultResultWriter(config.Logger, config.Alerter), + ) + + handler.ServeHTTP(rr, req) + gotUser := &types.CreateUserResponse{} + + // apitest.AssertResponseExpected(t, rr, expUser, gotUser) + err := json.NewDecoder(rr.Body).Decode(gotUser) + if err != nil { + t.Fatal(err) + } + + // This is the default lenth of a shortuuid + desiredLenth := 22 + assert.NotEmpty(t, gotUser.ReferralCode, "referral code should not be empty") + assert.Len(t, gotUser.ReferralCode, desiredLenth, "referral code should be 20 characters long") + assert.Equal(t, gotUser.ReferralRewardClaimed, false, "referral reward claimed should be false for new user") +} diff --git a/internal/repository/test/referrral.go b/internal/repository/test/referrral.go new file mode 100644 index 0000000000..7a58793537 --- /dev/null +++ b/internal/repository/test/referrral.go @@ -0,0 +1,24 @@ +package test + +import ( + "errors" + + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/repository" +) + +// ReferralRepository represents the set of queries on the Referral model +type ReferralRepository struct{} + +// NewAppInstanceRepository returns the test AppInstanceRepository +func NewReferralRepository() repository.ReferralRepository { + return &ReferralRepository{} +} + +func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*models.Referral, error) { + return referral, errors.New("cannot read database") +} + +func (repo *ReferralRepository) GetReferralCountByUserID(userID uint) (int, error) { + return 0, errors.New("cannot read database") +} diff --git a/internal/repository/test/repository.go b/internal/repository/test/repository.go index 26905a0364..1927adf491 100644 --- a/internal/repository/test/repository.go +++ b/internal/repository/test/repository.go @@ -59,6 +59,7 @@ type TestRepository struct { githubWebhook repository.GithubWebhookRepository datastore repository.DatastoreRepository appInstance repository.AppInstanceRepository + referral repository.ReferralRepository } func (t *TestRepository) User() repository.UserRepository { @@ -283,6 +284,11 @@ func (t *TestRepository) AppInstance() repository.AppInstanceRepository { return t.appInstance } +// Referral returns a test Referral +func (t *TestRepository) Referral() repository.ReferralRepository { + return t.referral +} + // NewRepository returns a Repository which persists users in memory // and accepts a parameter that can trigger read/write errors func NewRepository(canQuery bool, failingMethods ...string) repository.Repository { @@ -341,5 +347,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor githubWebhook: NewGithubWebhookRepository(), datastore: NewDatastoreRepository(), appInstance: NewAppInstanceRepository(), + referral: NewReferralRepository(), } } From 41495c7220d52359e8f5549936ce2fe7fb05cadf Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 10:52:07 -0400 Subject: [PATCH 06/17] Add referral link --- api/server/handlers/user/create_test.go | 2 +- dashboard/src/lib/hooks/useStripe.tsx | 2 +- dashboard/src/main/auth/Register.tsx | 11 +++++++++++ .../src/main/home/project-settings/ReferralsPage.tsx | 12 ++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/api/server/handlers/user/create_test.go b/api/server/handlers/user/create_test.go index 5f724ab044..de502d7c72 100644 --- a/api/server/handlers/user/create_test.go +++ b/api/server/handlers/user/create_test.go @@ -245,6 +245,6 @@ func TestCreateUserReferralCode(t *testing.T) { // This is the default lenth of a shortuuid desiredLenth := 22 assert.NotEmpty(t, gotUser.ReferralCode, "referral code should not be empty") - assert.Len(t, gotUser.ReferralCode, desiredLenth, "referral code should be 20 characters long") + assert.Len(t, gotUser.ReferralCode, desiredLenth, "referral code should be 22 characters long") assert.Equal(t, gotUser.ReferralRewardClaimed, false, "referral reward claimed should be false for new user") } diff --git a/dashboard/src/lib/hooks/useStripe.tsx b/dashboard/src/lib/hooks/useStripe.tsx index c83bbdad56..07c1e813ae 100644 --- a/dashboard/src/lib/hooks/useStripe.tsx +++ b/dashboard/src/lib/hooks/useStripe.tsx @@ -471,4 +471,4 @@ export const useClaimReferralReward = (): (() => void) => { // Return a function that can be called to execute the mutation return () => referralsReq.mutate(); -}; \ No newline at end of file +}; diff --git a/dashboard/src/main/auth/Register.tsx b/dashboard/src/main/auth/Register.tsx index c78bc96ff4..3d21871452 100644 --- a/dashboard/src/main/auth/Register.tsx +++ b/dashboard/src/main/auth/Register.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useState } from "react"; import styled from "styled-components"; +import { useLocation } from "react-router-dom"; import Heading from "components/form-components/Heading"; import Button from "components/porter/Button"; @@ -74,6 +75,16 @@ const Register: React.FC = ({ authenticate }) => { { value: "Other", label: "Other" }, ]; + const { search } = useLocation() + const searchParams = new URLSearchParams(search) + const referralCodeFromUrl = searchParams.get("referral") + + useEffect(() => { + if (referralCodeFromUrl) { + setReferralCode(referralCodeFromUrl); + } + }, [referralCodeFromUrl]); // Only re-run the effect if referralCodeFromUrl changes + const handleRegister = (): void => { const isHosted = window.location.hostname === "cloud.porter.run"; if (!emailRegex.test(email)) { diff --git a/dashboard/src/main/home/project-settings/ReferralsPage.tsx b/dashboard/src/main/home/project-settings/ReferralsPage.tsx index 4bc811740d..c0cecb72a3 100644 --- a/dashboard/src/main/home/project-settings/ReferralsPage.tsx +++ b/dashboard/src/main/home/project-settings/ReferralsPage.tsx @@ -3,12 +3,14 @@ import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { useClaimReferralReward, useReferralDetails, useReferrals } from "lib/hooks/useStripe"; import Button from "components/porter/Button"; +import Link from "components/porter/Link"; function ReferralsPage(): JSX.Element { const referralRewardRequirement = 5; const { referralDetails } = useReferralDetails(); const { referralsCount } = useReferrals(); const claimReferralReward = useClaimReferralReward(); + const baseUrl = window.location.origin; const eligibleForReward = (): boolean => { if (referralsCount === null) { @@ -66,6 +68,16 @@ function ReferralsPage(): JSX.Element { Refer people to Porter to earn credits. + {referralDetails !== null && ( + <> + + Your referral link is {" "} + + Date: Tue, 30 Apr 2024 12:02:55 -0400 Subject: [PATCH 08/17] 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 631a5fe895..2f348e2ea8 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 5788bb25c2..65f5aad010 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 5e098ce9c0..dbdb50b154 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 0aa12edc80..f4d1c79e64 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 bcb04a9645..0af9172324 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 8726cb946d..5659c81449 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 { From 75c2941cbd53e2c08fc921b235d3a9e1d90dbaf9 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 15:34:26 -0400 Subject: [PATCH 09/17] Per project referral --- api/server/handlers/billing/create.go | 55 +++++++++++++ api/server/handlers/billing/credits.go | 80 ------------------ api/server/handlers/project/create.go | 3 + api/server/handlers/project/referrals.go | 79 ++++++++++++++++++ api/server/handlers/user/create.go | 4 +- api/server/handlers/user/create_test.go | 58 +------------ api/server/handlers/user/github_callback.go | 3 - api/server/handlers/user/google_callback.go | 3 - api/server/handlers/user/referrals.go | 81 ------------------- api/server/router/project.go | 16 ++-- api/server/router/user.go | 48 ----------- api/types/billing_metronome.go | 1 + api/types/project.go | 2 + api/types/user.go | 8 +- dashboard/src/lib/billing/types.tsx | 6 +- dashboard/src/lib/hooks/useStripe.tsx | 69 +--------------- .../home/project-settings/ReferralsPage.tsx | 58 +------------ dashboard/src/shared/api.tsx | 26 +----- internal/billing/metronome.go | 13 ++- internal/models/project.go | 7 ++ internal/models/referral.go | 11 ++- internal/models/user.go | 22 ++--- internal/repository/gorm/referrals.go | 35 ++++++-- internal/repository/referral.go | 4 +- internal/repository/test/referrral.go | 10 ++- 25 files changed, 235 insertions(+), 467 deletions(-) create mode 100644 api/server/handlers/project/referrals.go delete mode 100644 api/server/handlers/user/referrals.go diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index c784d377d3..3bcdd758c1 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -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" @@ -15,6 +17,15 @@ import ( "github.com/porter-dev/porter/internal/telemetry" ) +const ( + // defaultRewardAmountCents is the default amount in USD cents rewarded to users + // who successfully refer a new user + defaultRewardAmountCents = 1000 + // defaultPaidAmountCents is the amount paid by the user to get the credits + // grant, if set to 0 it means they are free + defaultPaidAmountCents = 0 +) + // CreateBillingHandler is a handler for creating payment methods type CreateBillingHandler struct { handlers.PorterHandlerWriter @@ -41,6 +52,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 { @@ -54,6 +66,15 @@ 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 { + // Only log the error in case the reward grant fails, but don't return an error to the fe + telemetry.Error(ctx, span, err, "error granting credits reward") + } + } + c.WriteResult(w, r, clientSecret) } @@ -104,3 +125,37 @@ 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") + } + + 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" + err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, defaultRewardAmountCents, defaultPaidAmountCents, 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 +} diff --git a/api/server/handlers/billing/credits.go b/api/server/handlers/billing/credits.go index 2f348e2ea8..a84799de06 100644 --- a/api/server/handlers/billing/credits.go +++ b/api/server/handlers/billing/credits.go @@ -2,7 +2,6 @@ package billing import ( "net/http" - "time" "github.com/porter-dev/porter/api/server/handlers" "github.com/porter-dev/porter/api/server/shared" @@ -13,18 +12,6 @@ import ( "github.com/porter-dev/porter/internal/telemetry" ) -const ( - // referralRewardRequirement is the number of referred users required to - // be granted a credits reward - referralRewardRequirement = 5 - // defaultRewardAmountUSD is the default amount in USD rewarded to users - // who reach the reward requirement - defaultRewardAmountCents = 2000 - // defaultPaidAmountUSD is the amount paid by the user to get the credits - // grant, if set to 0 it means they were free - defaultPaidAmountCents = 0 -) - // ListCreditsHandler is a handler for getting available credits type ListCreditsHandler struct { handlers.PorterHandlerWriter @@ -70,70 +57,3 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.WriteResult(w, r, credits) } - -// ClaimReferralRewardHandler is a handler for granting credits -type ClaimReferralRewardHandler struct { - handlers.PorterHandlerWriter -} - -// NewClaimReferralReward will create a new GrantCreditsHandler -func NewClaimReferralReward( - config *config.Config, - writer shared.ResultWriter, -) *ClaimReferralRewardHandler { - return &ClaimReferralRewardHandler{ - PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), - } -} - -func (c *ClaimReferralRewardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-claim-credits-reward") - defer span.End() - - proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - user, _ := ctx.Value(types.UserScope).(*models.User) - - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { - 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 - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - telemetry.AttributeKV{Key: "referral-code", Value: user.ReferralCode}, - telemetry.AttributeKV{Key: "referral-reward-received", Value: user.ReferralRewardClaimed}, - ) - - // Check if the user is eligible for the referral reward - referralCount, err := c.Repo().Referral().GetReferralCountByUserID(user.ID) - if err != nil { - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) - return - } - - if !user.ReferralRewardClaimed && referralCount >= referralRewardRequirement { - // 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 - } - - user.ReferralRewardClaimed = true - _, err = c.Repo().User().UpdateUser(user) - if err != nil { - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) - return - } - } - - c.WriteResult(w, r, "") -} diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index 27ff346f56..af891b2cec 100644 --- a/api/server/handlers/project/create.go +++ b/api/server/handlers/project/create.go @@ -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 diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go new file mode 100644 index 0000000000..0e0d4310ef --- /dev/null +++ b/api/server/handlers/project/referrals.go @@ -0,0 +1,79 @@ +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"` + }{ + Code: proj.ReferralCode, + ReferralCount: referralCount, + } + + c.WriteResult(w, r, referralCodeResponse) +} diff --git a/api/server/handlers/user/create.go b/api/server/handlers/user/create.go index 5d1cbe829c..403a265346 100644 --- a/api/server/handlers/user/create.go +++ b/api/server/handlers/user/create.go @@ -70,9 +70,6 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user.Password = string(hashedPw) - // Generate referral code for user - user.ReferralCode = models.NewReferralCode() - // write the user to the db user, err = u.Repo().User().CreateUser(user) if err != nil { @@ -106,6 +103,7 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { referral := &models.Referral{ Code: request.ReferredBy, ReferredUserID: user.ID, + Status: models.ReferralStatusSignedUp, } _, err = u.Repo().Referral().CreateReferral(referral) diff --git a/api/server/handlers/user/create_test.go b/api/server/handlers/user/create_test.go index de502d7c72..273f0b2429 100644 --- a/api/server/handlers/user/create_test.go +++ b/api/server/handlers/user/create_test.go @@ -1,7 +1,6 @@ package user_test import ( - "encoding/json" "net/http" "testing" @@ -10,7 +9,6 @@ import ( "github.com/porter-dev/porter/api/server/shared/apitest" "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/internal/repository/test" - "github.com/stretchr/testify/assert" ) func TestCreateUserSuccessful(t *testing.T) { @@ -40,14 +38,7 @@ func TestCreateUserSuccessful(t *testing.T) { // Use a struct that is the same as types.User but without the // referral fields. This is because the referral code is randomly // generated and is tested separately. - expUser := &struct { - ID uint `json:"id"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - CompanyName string `json:"company_name"` - }{ + expUser := &types.CreateUserResponse{ ID: 1, FirstName: "Mister", LastName: "Porter", @@ -56,14 +47,7 @@ func TestCreateUserSuccessful(t *testing.T) { EmailVerified: false, } - gotUser := &struct { - ID uint `json:"id"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - CompanyName string `json:"company_name"` - }{} + gotUser := &types.CreateUserResponse{} apitest.AssertResponseExpected(t, rr, expUser, gotUser) } @@ -210,41 +194,3 @@ func TestFailingCreateSessionMethod(t *testing.T) { apitest.AssertResponseInternalServerError(t, rr) } - -func TestCreateUserReferralCode(t *testing.T) { - req, rr := apitest.GetRequestAndRecorder( - t, - string(types.HTTPVerbPost), - "/api/users", - &types.CreateUserRequest{ - FirstName: "Mister", - LastName: "Porter", - CompanyName: "Porter Technologies, Inc.", - Email: "mrp@porter.run", - Password: "somepassword", - }, - ) - - config := apitest.LoadConfig(t) - - handler := user.NewUserCreateHandler( - config, - shared.NewDefaultRequestDecoderValidator(config.Logger, config.Alerter), - shared.NewDefaultResultWriter(config.Logger, config.Alerter), - ) - - handler.ServeHTTP(rr, req) - gotUser := &types.CreateUserResponse{} - - // apitest.AssertResponseExpected(t, rr, expUser, gotUser) - err := json.NewDecoder(rr.Body).Decode(gotUser) - if err != nil { - t.Fatal(err) - } - - // This is the default lenth of a shortuuid - desiredLenth := 22 - assert.NotEmpty(t, gotUser.ReferralCode, "referral code should not be empty") - assert.Len(t, gotUser.ReferralCode, desiredLenth, "referral code should be 22 characters long") - assert.Equal(t, gotUser.ReferralRewardClaimed, false, "referral reward claimed should be false for new user") -} diff --git a/api/server/handlers/user/github_callback.go b/api/server/handlers/user/github_callback.go index 2cec4ed5a2..f6fdb11144 100644 --- a/api/server/handlers/user/github_callback.go +++ b/api/server/handlers/user/github_callback.go @@ -146,9 +146,6 @@ func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User GithubUserID: githubUser.GetID(), } - // Generate referral code for user - user.ReferralCode = models.NewReferralCode() - user, err = config.Repo.User().CreateUser(user) if err != nil { return nil, err diff --git a/api/server/handlers/user/google_callback.go b/api/server/handlers/user/google_callback.go index 313ad46b23..08cdf4beae 100644 --- a/api/server/handlers/user/google_callback.go +++ b/api/server/handlers/user/google_callback.go @@ -132,9 +132,6 @@ func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*model GoogleUserID: gInfo.Sub, } - // Generate referral code for user - user.ReferralCode = models.NewReferralCode() - user, err = config.Repo.User().CreateUser(user) if err != nil { return nil, err diff --git a/api/server/handlers/user/referrals.go b/api/server/handlers/user/referrals.go deleted file mode 100644 index 1be2df0a69..0000000000 --- a/api/server/handlers/user/referrals.go +++ /dev/null @@ -1,81 +0,0 @@ -package user - -import ( - "net/http" - - "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" -) - -// ListUserReferralsHandler is a handler for getting a list of user referrals -type ListUserReferralsHandler struct { - handlers.PorterHandlerWriter -} - -// NewListUserReferralsHandler returns an instance of ListUserReferralsHandler -func NewListUserReferralsHandler( - config *config.Config, - writer shared.ResultWriter, -) *ListUserReferralsHandler { - return &ListUserReferralsHandler{ - PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), - } -} - -func (u *ListUserReferralsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-list-user-referrals") - defer span.End() - - user, _ := ctx.Value(types.UserScope).(*models.User) - - referralCount, err := u.Repo().Referral().GetReferralCountByUserID(user.ID) - if err != nil { - u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) - return - } - - referralResponse := struct { - ReferralCount int `json:"count"` - }{ - ReferralCount: referralCount, - } - - u.WriteResult(w, r, referralResponse) -} - -// GetUserReferralDetailsHandler is a handler for getting a user's referral code -type GetUserReferralDetailsHandler struct { - handlers.PorterHandlerWriter -} - -// NewGetUserReferralDetailsHandler returns an instance of GetUserReferralCodeHandler -func NewGetUserReferralDetailsHandler( - config *config.Config, - writer shared.ResultWriter, -) *GetUserReferralDetailsHandler { - return &GetUserReferralDetailsHandler{ - PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), - } -} - -func (u *GetUserReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-get-user-referral-details") - defer span.End() - - user, _ := ctx.Value(types.UserScope).(*models.User) - - referralCodeResponse := struct { - Code string `json:"code"` - RewardClaimed bool `json:"reward_claimed"` - }{ - Code: user.ReferralCode, - RewardClaimed: user.ReferralRewardClaimed, - } - - u.WriteResult(w, r, referralCodeResponse) -} diff --git a/api/server/router/project.go b/api/server/router/project.go index 6b78f68d51..e44fed515d 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -397,14 +397,14 @@ func getProjectRoutes( Router: r, }) - // GET /api/projects/{project_id}/billing/credits/claim_referral -> project.NewGetCreditsHandler - claimReferralRewardEndpoint := factory.NewAPIEndpoint( + // GET /api/projects/{project_id}/referrals/details -> user.NewGetUserReferralDetailsHandler + getReferralDetailsEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ - Verb: types.APIVerbCreate, - Method: types.HTTPVerbPost, + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, Path: &types.Path{ Parent: basePath, - RelativePath: relPath + "/billing/credits/claim_referral", + RelativePath: relPath + "/referrals/details", }, Scopes: []types.PermissionScope{ types.UserScope, @@ -413,14 +413,14 @@ func getProjectRoutes( }, ) - claimReferralRewardHandler := billing.NewClaimReferralReward( + getReferralDetailsHandler := project.NewGetProjectReferralDetailsHandler( config, factory.GetResultWriter(), ) routes = append(routes, &router.Route{ - Endpoint: claimReferralRewardEndpoint, - Handler: claimReferralRewardHandler, + Endpoint: getReferralDetailsEndpoint, + Handler: getReferralDetailsHandler, Router: r, }) diff --git a/api/server/router/user.go b/api/server/router/user.go index 4d8f736ccc..ea90ba5122 100644 --- a/api/server/router/user.go +++ b/api/server/router/user.go @@ -472,53 +472,5 @@ func getUserRoutes( Router: r, }) - // GET /api/referrals -> user.NewListUserReferralsHandler - listReferralsEndpoint := factory.NewAPIEndpoint( - &types.APIRequestMetadata{ - Verb: types.APIVerbGet, - Method: types.HTTPVerbGet, - Path: &types.Path{ - Parent: basePath, - RelativePath: "/referrals", - }, - Scopes: []types.PermissionScope{types.UserScope}, - }, - ) - - listReferralsHandler := user.NewListUserReferralsHandler( - config, - factory.GetResultWriter(), - ) - - routes = append(routes, &router.Route{ - Endpoint: listReferralsEndpoint, - Handler: listReferralsHandler, - Router: r, - }) - - // GET /api/referrals/details -> user.NewGetUserReferralDetailsHandler - getReferralDetailsEndpoint := factory.NewAPIEndpoint( - &types.APIRequestMetadata{ - Verb: types.APIVerbGet, - Method: types.HTTPVerbGet, - Path: &types.Path{ - Parent: basePath, - RelativePath: "/referrals/details", - }, - Scopes: []types.PermissionScope{types.UserScope}, - }, - ) - - getReferralDetailsHandler := user.NewGetUserReferralDetailsHandler( - config, - factory.GetResultWriter(), - ) - - routes = append(routes, &router.Route{ - Endpoint: getReferralDetailsEndpoint, - Handler: getReferralDetailsHandler, - Router: r, - }) - return routes } diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 65f5aad010..5b77a2fc98 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -65,6 +65,7 @@ type CreateCreditsGrantRequest struct { 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 diff --git a/api/types/project.go b/api/types/project.go index 1bf9497dac..3f537cccf7 100644 --- a/api/types/project.go +++ b/api/types/project.go @@ -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 diff --git a/api/types/user.go b/api/types/user.go index db103e1e77..41b33b6b34 100644 --- a/api/types/user.go +++ b/api/types/user.go @@ -7,12 +7,6 @@ type User struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` CompanyName string `json:"company_name"` - - // ReferralCode is a unique code that can be shared to referr other users to Porter - ReferralCode string `json:"referral_code"` - // ReferralRewardClaimed indicates if the user has already received a credits reward - // for referring users - ReferralRewardClaimed bool `json:"referral_reward_received"` } type CreateUserRequest struct { @@ -22,7 +16,7 @@ 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 user who referred this user + // ReferredBy is the referral code of the project from which this user was referred ReferredBy string `json:"referred_by_code" form:"max=255"` } diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 732afc5ac4..9a64261975 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -54,9 +54,5 @@ export const ClientSecretResponse = z.string(); export type ReferralDetails = z.infer; export const ReferralDetailsValidator = z.object({ code: z.string(), - reward_claimed: z.boolean(), -}).nullable(); - -export const ReferralsValidator = z.object({ - count: z.number(), + referral_count: z.number(), }).nullable(); diff --git a/dashboard/src/lib/hooks/useStripe.tsx b/dashboard/src/lib/hooks/useStripe.tsx index 07c1e813ae..037da5d6a2 100644 --- a/dashboard/src/lib/hooks/useStripe.tsx +++ b/dashboard/src/lib/hooks/useStripe.tsx @@ -1,5 +1,5 @@ import { useContext, useState } from "react"; -import { useQuery, useMutation, type UseQueryResult } from "@tanstack/react-query"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { z } from "zod"; import { @@ -13,7 +13,6 @@ import { type PaymentMethod, type PaymentMethodList, type UsageList, - ReferralsValidator, ReferralDetailsValidator, ReferralDetails } from "lib/billing/types"; @@ -67,10 +66,6 @@ type TGetReferralDetails = { referralDetails: ReferralDetails }; -type TGetReferrals = { - referralsCount: number | null; -}; - export const usePaymentMethods = (): TUsePaymentMethod => { const { currentProject } = useContext(Context); @@ -379,36 +374,6 @@ export const useCustomerUsage = ( }; }; -export const useReferrals = (): TGetReferrals => { - const { currentProject } = useContext(Context); - - // Fetch referrals count - const referralsReq = useQuery( - ["getReferrals", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - try { - const res = await api.getReferrals( - "", - {}, - {} - ); - - const referrals = ReferralsValidator.parse(res.data); - return referrals?.count ?? null; - } catch (error) { - return null - } - }); - - return { - referralsCount: referralsReq.data ?? null, - }; -}; - export const useReferralDetails = (): TGetReferralDetails => { const { currentProject } = useContext(Context); @@ -428,7 +393,7 @@ export const useReferralDetails = (): TGetReferralDetails => { const res = await api.getReferralDetails( "", {}, - {} + { project_id: currentProject?.id } ); const referraldetails = ReferralDetailsValidator.parse(res.data); @@ -442,33 +407,3 @@ export const useReferralDetails = (): TGetReferralDetails => { referralDetails: referralsReq.data ?? null, }; }; - -export const useClaimReferralReward = (): (() => void) => { - const { currentProject } = useContext(Context); - - // Apply credits reward to this project - const referralsReq = useMutation( - ["claimReferralReward", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return; - } - - if (!currentProject?.id || currentProject.id === -1) { - return; - } - - try { - await api.claimReferralReward( - "", - {}, - { project_id: currentProject?.id } - ); - } catch (error) { - return; - } - }); - - // Return a function that can be called to execute the mutation - return () => referralsReq.mutate(); -}; diff --git a/dashboard/src/main/home/project-settings/ReferralsPage.tsx b/dashboard/src/main/home/project-settings/ReferralsPage.tsx index c0cecb72a3..226ca0b238 100644 --- a/dashboard/src/main/home/project-settings/ReferralsPage.tsx +++ b/dashboard/src/main/home/project-settings/ReferralsPage.tsx @@ -1,65 +1,13 @@ import React from "react"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; -import { useClaimReferralReward, useReferralDetails, useReferrals } from "lib/hooks/useStripe"; -import Button from "components/porter/Button"; +import { useReferralDetails } from "lib/hooks/useStripe"; import Link from "components/porter/Link"; function ReferralsPage(): JSX.Element { - const referralRewardRequirement = 5; const { referralDetails } = useReferralDetails(); - const { referralsCount } = useReferrals(); - const claimReferralReward = useClaimReferralReward(); const baseUrl = window.location.origin; - const eligibleForReward = (): boolean => { - if (referralsCount === null) { - return false; - } - - return referralsCount >= referralRewardRequirement; - } - - const claimReward = (): void => { - claimReferralReward(); - } - - const displayReferral = (): JSX.Element => { - if (referralDetails === null || referralsCount === null) { - return <> - } - - if (!eligibleForReward()) { - return ( - <> - - Refer {referralRewardRequirement - referralsCount} more people to earn a reward. - - - - ) - } - - if (referralDetails?.reward_claimed) { - return ( - <> - - You have already claimed a reward for referring people to Porter. - - - - ) - } - - return ( - <> - You are elegible for claiming a reward on this project. - - - - ) - } - return ( <> Referrals @@ -74,12 +22,12 @@ function ReferralsPage(): JSX.Element { Your referral link is {" "} - `/api/referrals` -); - const getReferralDetails = baseApi< - {}, - {} ->( - "GET", - () => - `/api/referrals/details` -); - -const claimReferralReward = baseApi< {}, { project_id?: number; } >( - "POST", - ({ project_id }) => `/api/projects/${project_id}/billing/credits/claim_referral` + "GET", + ({ project_id }) => + `/api/projects/${project_id}/referrals/details` ); + const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`); const createSecretAndOpenGitHubPullRequest = baseApi< @@ -3997,9 +3981,7 @@ export default { addPaymentMethod, setDefaultPaymentMethod, deletePaymentMethod, - getReferrals, getReferralDetails, - claimReferralReward, // STATUS getGithubStatus, diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index f4d1c79e64..1b7c33c4b0 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -20,6 +20,16 @@ const ( defaultCollectionMethod = "charge_automatically" defaultMaxRetries = 10 porterStandardTrialDays = 15 + + // referralRewardRequirement is the number of referred users required to + // be granted a credits reward + referralRewardRequirement = 5 + // defaultRewardAmountCents is the default amount in USD cents rewarded to users + // who reach the reward requirement + defaultRewardAmountCents = 1000 + // defaultPaidAmountCents is the amount paid by the user to get the credits + // grant, if set to 0 it means they were free + defaultPaidAmountCents = 0 ) // MetronomeClient is the client used to call the Metronome API @@ -243,7 +253,7 @@ func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uui } // 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) { +func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, reason string, grantAmount float64, paidAmount float64, expiresAt string) (err error) { ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") defer span.End() @@ -272,6 +282,7 @@ func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid CreditTypeID: creditTypeID, }, Name: "Porter Credits", + Reason: reason, ExpiresAt: expiresAt, Priority: 1, } diff --git a/internal/models/project.go b/internal/models/project.go index 058ac3e86a..4447d35c14 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -226,6 +226,12 @@ type Project struct { EnableReprovision bool `gorm:"default:false"` AdvancedInfraEnabled bool `gorm:"default:false"` AdvancedRbacEnabled bool `gorm:"default:false"` + + // ReferralCode is a unique code that can be shared to referr other users to Porter + ReferralCode string + + // Referrals is a list of users that have been referred by this project's code + Referrals []Referral `json:"referrals"` } // GetFeatureFlag calls launchdarkly for the specified flag @@ -332,6 +338,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje AdvancedInfraEnabled: p.GetFeatureFlag(AdvancedInfraEnabled, launchDarklyClient), SandboxEnabled: p.EnableSandbox, AdvancedRbacEnabled: p.GetFeatureFlag(AdvancedRbacEnabled, launchDarklyClient), + ReferralCode: p.ReferralCode, } } diff --git a/internal/models/referral.go b/internal/models/referral.go index 0af9172324..0dc3ce1147 100644 --- a/internal/models/referral.go +++ b/internal/models/referral.go @@ -6,14 +6,21 @@ import ( "gorm.io/gorm" ) +const ( + // ReferralStatusSignedUp is the status of a referral where the referred user has signed up + ReferralStatusSignedUp = "signed_up" + // ReferralStatusCompleted is the status of a referral where the referred user has linked a credit card + ReferralStatusCompleted = "completed" +) + // Referral type that extends gorm.Model type Referral struct { gorm.Model // Code is the referral code that is shared with the referred user Code string - // UserID is the ID of the user who made the referral - UserID uint + // ProjectID is the ID of the project that was used to refer a new user + ProjectID uint // ReferredUserID is the ID of the user who was referred ReferredUserID uint // Status is the status of the referral (pending, signed_up, etc.) diff --git a/internal/models/user.go b/internal/models/user.go index 4db859d3c2..6d98c0faa8 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,26 +23,16 @@ type User struct { // The github user id used for login (optional) GithubUserID int64 GoogleUserID string - - // ReferralCode is a unique code that can be shared to referr other users to Porter - ReferralCode string - // ReferralRewardClaimed indicates if the user has already received a credits reward - // for referring users - ReferralRewardClaimed bool - - Referrals []Referral `json:"referrals"` } // ToUserType generates an external types.User to be shared over REST func (u *User) ToUserType() *types.User { return &types.User{ - ID: u.ID, - Email: u.Email, - EmailVerified: u.EmailVerified, - FirstName: u.FirstName, - LastName: u.LastName, - CompanyName: u.CompanyName, - ReferralCode: u.ReferralCode, - ReferralRewardClaimed: u.ReferralRewardClaimed, + ID: u.ID, + Email: u.Email, + EmailVerified: u.EmailVerified, + FirstName: u.FirstName, + LastName: u.LastName, + CompanyName: u.CompanyName, } } diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index 5659c81449..e7d7b9b59e 100644 --- a/internal/repository/gorm/referrals.go +++ b/internal/repository/gorm/referrals.go @@ -19,13 +19,13 @@ func NewReferralRepository(db *gorm.DB) repository.ReferralRepository { // CreateReferral creates a new referral in the database func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*models.Referral, error) { - user := &models.User{} + project := &models.Project{} - if err := repo.db.Where("referral_code = ?", referral.Code).First(&user).Error; err != nil { + if err := repo.db.Where("referral_code = ?", referral.Code).First(&project).Error; err != nil { return nil, err } - assoc := repo.db.Model(&user).Association("Referrals") + assoc := repo.db.Model(&project).Association("Referrals") if assoc.Error != nil { return nil, assoc.Error @@ -38,11 +38,30 @@ 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 { +// CountReferralsByProjectID returns the number of referrals a user has made +func (repo *ReferralRepository) CountReferralsByProjectID(projectID uint, status string) (int64, error) { + var count int64 + + if err := repo.db.Model(&models.Referral{}).Where("project_id = ? AND status = ?", projectID, status).Count(&count).Error; err != nil { return 0, err } - return len(referrals), nil + + return count, nil +} + +// GetReferralByCode returns the number of referrals a user has made +func (repo *ReferralRepository) GetReferralByReferredID(referredID uint) (*models.Referral, error) { + referral := &models.Referral{} + if err := repo.db.Where("referred_user_id = ?", referredID).First(&referral).Error; err != nil { + return &models.Referral{}, err + } + return referral, nil +} + +func (repo *ReferralRepository) UpdateReferral(referral *models.Referral) (*models.Referral, error) { + if err := repo.db.Save(referral).Error; err != nil { + return nil, err + } + + return referral, nil } diff --git a/internal/repository/referral.go b/internal/repository/referral.go index f25429c972..4b6ff73502 100644 --- a/internal/repository/referral.go +++ b/internal/repository/referral.go @@ -7,5 +7,7 @@ import ( // ReferralRepository represents the set of queries on the Referral model type ReferralRepository interface { CreateReferral(referral *models.Referral) (*models.Referral, error) - GetReferralCountByUserID(userID uint) (int, error) + GetReferralByReferredID(referredID uint) (*models.Referral, error) + CountReferralsByProjectID(projectID uint, status string) (int64, error) + UpdateReferral(referral *models.Referral) (*models.Referral, error) } diff --git a/internal/repository/test/referrral.go b/internal/repository/test/referrral.go index 7a58793537..6de00ad0db 100644 --- a/internal/repository/test/referrral.go +++ b/internal/repository/test/referrral.go @@ -19,6 +19,14 @@ func (repo *ReferralRepository) CreateReferral(referral *models.Referral) (*mode return referral, errors.New("cannot read database") } -func (repo *ReferralRepository) GetReferralCountByUserID(userID uint) (int, error) { +func (repo *ReferralRepository) CountReferralsByProjectID(projectID uint, status string) (int64, error) { return 0, errors.New("cannot read database") } + +func (repo *ReferralRepository) GetReferralByReferredID(referredID uint) (*models.Referral, error) { + return &models.Referral{}, errors.New("cannot read database") +} + +func (repo *ReferralRepository) UpdateReferral(referral *models.Referral) (*models.Referral, error) { + return referral, errors.New("cannot read database") +} From 1d42e41911612821ea15ba4b8ba409733e07a8a6 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 15:41:08 -0400 Subject: [PATCH 10/17] Remove unnecessary changes --- api/server/handlers/billing/credits.go | 59 --------------------- api/server/handlers/billing/plan.go | 46 ++++++++++++++++ api/server/handlers/user/create_test.go | 7 +-- api/server/handlers/user/github_callback.go | 3 ++ api/server/handlers/user/google_callback.go | 3 ++ internal/billing/metronome.go | 10 ---- 6 files changed, 54 insertions(+), 74 deletions(-) delete mode 100644 api/server/handlers/billing/credits.go diff --git a/api/server/handlers/billing/credits.go b/api/server/handlers/billing/credits.go deleted file mode 100644 index a84799de06..0000000000 --- a/api/server/handlers/billing/credits.go +++ /dev/null @@ -1,59 +0,0 @@ -package billing - -import ( - "net/http" - - "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" -) - -// ListCreditsHandler is a handler for getting available credits -type ListCreditsHandler struct { - handlers.PorterHandlerWriter -} - -// NewListCreditsHandler will create a new ListCreditsHandler -func NewListCreditsHandler( - config *config.Config, - writer shared.ResultWriter, -) *ListCreditsHandler { - return &ListCreditsHandler{ - PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), - } -} - -func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-list-credits") - defer span.End() - - proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { - 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 - } - - credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID) - if err != nil { - err := telemetry.Error(ctx, span, err, "error listing credits") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - ) - - c.WriteResult(w, r, credits) -} diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 1b0cc2646a..1d5f0ed70d 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -58,6 +58,52 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.WriteResult(w, r, plan) } +// ListCreditsHandler is a handler for getting available credits +type ListCreditsHandler struct { + handlers.PorterHandlerWriter +} + +// NewListCreditsHandler will create a new ListCreditsHandler +func NewListCreditsHandler( + config *config.Config, + writer shared.ResultWriter, +) *ListCreditsHandler { + return &ListCreditsHandler{ + PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer), + } +} + +func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-credits") + defer span.End() + + proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + + if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + 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 + } + + credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error listing credits") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, + telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, + ) + + c.WriteResult(w, r, credits) +} + // ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours. type ListCustomerUsageHandler struct { handlers.PorterHandlerReadWriter diff --git a/api/server/handlers/user/create_test.go b/api/server/handlers/user/create_test.go index 273f0b2429..b130606f57 100644 --- a/api/server/handlers/user/create_test.go +++ b/api/server/handlers/user/create_test.go @@ -35,10 +35,7 @@ func TestCreateUserSuccessful(t *testing.T) { handler.ServeHTTP(rr, req) - // Use a struct that is the same as types.User but without the - // referral fields. This is because the referral code is randomly - // generated and is tested separately. - expUser := &types.CreateUserResponse{ + expUser := &types.CreateUserResponse{ ID: 1, FirstName: "Mister", LastName: "Porter", @@ -47,7 +44,7 @@ func TestCreateUserSuccessful(t *testing.T) { EmailVerified: false, } - gotUser := &types.CreateUserResponse{} + gotUser := &types.CreateUserResponse{} apitest.AssertResponseExpected(t, rr, expUser, gotUser) } diff --git a/api/server/handlers/user/github_callback.go b/api/server/handlers/user/github_callback.go index f6fdb11144..c6fc15c4ed 100644 --- a/api/server/handlers/user/github_callback.go +++ b/api/server/handlers/user/github_callback.go @@ -85,6 +85,7 @@ func (p *UserOAuthGithubCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt // non-fatal send email verification if !user.EmailVerified { err = startEmailVerification(p.Config(), w, r, user) + if err != nil { p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } @@ -147,11 +148,13 @@ func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User } user, err = config.Repo.User().CreateUser(user) + if err != nil { return nil, err } err = addUserToDefaultProject(config, user) + if err != nil { return nil, err } diff --git a/api/server/handlers/user/google_callback.go b/api/server/handlers/user/google_callback.go index 08cdf4beae..472837c59a 100644 --- a/api/server/handlers/user/google_callback.go +++ b/api/server/handlers/user/google_callback.go @@ -88,6 +88,7 @@ func (p *UserOAuthGoogleCallbackHandler) ServeHTTP(w http.ResponseWriter, r *htt // non-fatal send email verification if !user.EmailVerified { err = startEmailVerification(p.Config(), w, r, user) + if err != nil { p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err)) } @@ -133,11 +134,13 @@ func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*model } user, err = config.Repo.User().CreateUser(user) + if err != nil { return nil, err } err = addUserToDefaultProject(config, user) + if err != nil { return nil, err } diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index 1b7c33c4b0..92cb399cd4 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -20,16 +20,6 @@ const ( defaultCollectionMethod = "charge_automatically" defaultMaxRetries = 10 porterStandardTrialDays = 15 - - // referralRewardRequirement is the number of referred users required to - // be granted a credits reward - referralRewardRequirement = 5 - // defaultRewardAmountCents is the default amount in USD cents rewarded to users - // who reach the reward requirement - defaultRewardAmountCents = 1000 - // defaultPaidAmountCents is the amount paid by the user to get the credits - // grant, if set to 0 it means they were free - defaultPaidAmountCents = 0 ) // MetronomeClient is the client used to call the Metronome API From 0559d7a123d0f99dbc921b37ab69942d086800c9 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 15:52:30 -0400 Subject: [PATCH 11/17] Linting and more sandbox checks --- api/server/handlers/billing/create.go | 5 +++-- api/server/handlers/project/referrals.go | 2 +- api/types/billing_metronome.go | 1 + .../src/main/home/project-settings/ProjectSettings.tsx | 10 ++++++---- internal/repository/gorm/referrals.go | 2 +- internal/repository/gorm/repository.go | 1 + 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 3bcdd758c1..54cf7233b2 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -70,8 +70,9 @@ func (c *CreateBillingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // Grant a reward to the project that referred this user after linking a payment method err = c.grantRewardIfReferral(ctx, user.ID) if err != nil { - // Only log the error in case the reward grant fails, but don't return an error to the fe - telemetry.Error(ctx, span, err, "error granting credits reward") + err := telemetry.Error(ctx, span, err, "error granting credits reward") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return } } diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 0e0d4310ef..c8d3030f72 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -35,7 +35,7 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h 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 { + proj.UsageID == uuid.Nil || !proj.EnableSandbox { c.WriteResult(w, r, "") telemetry.WithAttributes(span, diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 5b77a2fc98..2347b504e8 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -159,6 +159,7 @@ type PaidAmount struct { 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"` diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index 58678ade02..d3be68a98d 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -96,10 +96,12 @@ function ProjectSettings(props: any) { }); } - tabOpts.push({ - value: "referrals", - label: "Referrals", - }); + if (currentProject?.sandbox_enabled && currentProject?.billing_enabled) { + tabOpts.push({ + value: "referrals", + label: "Referrals", + }); + } tabOpts.push({ value: "additional-settings", diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index e7d7b9b59e..7cc08ef734 100644 --- a/internal/repository/gorm/referrals.go +++ b/internal/repository/gorm/referrals.go @@ -49,7 +49,7 @@ func (repo *ReferralRepository) CountReferralsByProjectID(projectID uint, status return count, nil } -// GetReferralByCode returns the number of referrals a user has made +// GetReferralByReferredID returns a referral by the referred user's ID func (repo *ReferralRepository) GetReferralByReferredID(referredID uint) (*models.Referral, error) { referral := &models.Referral{} if err := repo.db.Where("referred_user_id = ?", referredID).First(&referral).Error; err != nil { diff --git a/internal/repository/gorm/repository.go b/internal/repository/gorm/repository.go index bce91a1249..724bcdcfb1 100644 --- a/internal/repository/gorm/repository.go +++ b/internal/repository/gorm/repository.go @@ -294,6 +294,7 @@ func (t *GormRepository) Ipam() repository.IpamRepository { return t.ipam } +// Referral returns the ReferralRepository interface implemented by gorm func (t *GormRepository) Referral() repository.ReferralRepository { return t.referral } From ba30180b1dc7f920949ccca25e4baa473e07f551 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 15:59:04 -0400 Subject: [PATCH 12/17] Linting --- internal/repository/gorm/referrals.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index 7cc08ef734..98db97609e 100644 --- a/internal/repository/gorm/referrals.go +++ b/internal/repository/gorm/referrals.go @@ -58,6 +58,7 @@ func (repo *ReferralRepository) GetReferralByReferredID(referredID uint) (*model return referral, nil } +// UpdateReferral updates a referral in the database func (repo *ReferralRepository) UpdateReferral(referral *models.Referral) (*models.Referral, error) { if err := repo.db.Save(referral).Error; err != nil { return nil, err From cb6f58dccf0ec5556fd65e8d0387a9a1176e872c Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 17:52:39 -0400 Subject: [PATCH 13/17] Dont fail when referral doesnt exist --- api/server/handlers/billing/create.go | 4 ++++ internal/repository/gorm/referrals.go | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 54cf7233b2..d44276a3c3 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -136,6 +136,10 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr return telemetry.Error(ctx, span, err, "failed to find referral by referred id") } + if referral == nil { + return nil + } + referrerProject, err := c.Repo().Project().ReadProject(referral.ProjectID) if err != nil { return telemetry.Error(ctx, span, err, "failed to find referrer project") diff --git a/internal/repository/gorm/referrals.go b/internal/repository/gorm/referrals.go index 98db97609e..5165fd17b3 100644 --- a/internal/repository/gorm/referrals.go +++ b/internal/repository/gorm/referrals.go @@ -1,6 +1,8 @@ package gorm import ( + "errors" + "github.com/porter-dev/porter/internal/models" "github.com/porter-dev/porter/internal/repository" "gorm.io/gorm" @@ -52,7 +54,12 @@ func (repo *ReferralRepository) CountReferralsByProjectID(projectID uint, status // GetReferralByReferredID returns a referral by the referred user's ID func (repo *ReferralRepository) GetReferralByReferredID(referredID uint) (*models.Referral, error) { referral := &models.Referral{} - if err := repo.db.Where("referred_user_id = ?", referredID).First(&referral).Error; err != nil { + err := repo.db.Where("referred_user_id = ?", referredID).First(&referral).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + + if err != nil { return &models.Referral{}, err } return referral, nil From 85f48060837f7fc5121f276db6a0f5c6cbe4da54 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 30 Apr 2024 21:58:40 -0400 Subject: [PATCH 14/17] Add max reward limit and fix expired plan bug --- api/server/handlers/billing/create.go | 11 +++++++++++ .../main/home/project-settings/BillingPage.tsx | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index d44276a3c3..bc4e1d0f1d 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -24,6 +24,8 @@ const ( // defaultPaidAmountCents is the amount paid by the user to get the credits // grant, if set to 0 it means they are free defaultPaidAmountCents = 0 + // maxReferralRewards is the maximum number of referral rewards a user can receive + maxReferralRewards = 10 ) // CreateBillingHandler is a handler for creating payment methods @@ -140,6 +142,15 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr 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") + } + + 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") diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 666090a1f2..dece428ff0 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -50,6 +50,19 @@ function BillingPage(): JSX.Element { const { usage } = useCustomerUsage("day", true); + const trialEnding = (starting_on: string, ending_before: string,): string => { + if (ending_before === undefined) { + return ""; + } + + const diff = dayjs(ending_before).diff(dayjs()); + if (diff <= 0) { + return `Started on ${readableDate(starting_on)}` + } + + return `Free trial ends ${dayjs().to(dayjs(ending_before))}` + } + const processedData = useMemo(() => { const before = usage; const resultMap = new Map(); @@ -235,8 +248,7 @@ function BillingPage(): JSX.Element { {plan.trial_info !== undefined && plan.trial_info.ending_before !== "" ? ( - Free trial ends{" "} - {dayjs().to(dayjs(plan.trial_info.ending_before))} + {trialEnding(plan.starting_on, plan.trial_info.ending_before)} ) : ( Started on {readableDate(plan.starting_on)} From eea2a25aadd52dd3a2d5c264176c2b063da4c4dc Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 1 May 2024 09:50:18 -0400 Subject: [PATCH 15/17] Add max reward limit --- api/server/handlers/billing/create.go | 16 ++----- api/server/handlers/project/referrals.go | 10 ++-- dashboard/src/lib/billing/types.tsx | 27 ++++++----- .../home/project-settings/ReferralsPage.tsx | 47 ++++++++++--------- internal/billing/metronome.go | 29 +++++++++--- 5 files changed, 72 insertions(+), 57 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index bc4e1d0f1d..881d8856d8 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -17,17 +17,6 @@ import ( "github.com/porter-dev/porter/internal/telemetry" ) -const ( - // defaultRewardAmountCents is the default amount in USD cents rewarded to users - // who successfully refer a new user - defaultRewardAmountCents = 1000 - // defaultPaidAmountCents is the amount paid by the user to get the credits - // grant, if set to 0 it means they are free - defaultPaidAmountCents = 0 - // maxReferralRewards is the maximum number of referral rewards a user can receive - maxReferralRewards = 10 -) - // CreateBillingHandler is a handler for creating payment methods type CreateBillingHandler struct { handlers.PorterHandlerWriter @@ -147,6 +136,7 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr 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 } @@ -161,7 +151,9 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr // 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" - err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, defaultRewardAmountCents, defaultPaidAmountCents, expiresAt) + 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") } diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index c8d3030f72..80669a2f95 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -68,11 +68,13 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h } referralCodeResponse := struct { - Code string `json:"code"` - ReferralCount int64 `json:"referral_count"` + Code string `json:"code"` + ReferralCount int64 `json:"referral_count"` + MaxAllowedRewards int64 `json:"max_allowed_referrals"` }{ - Code: proj.ReferralCode, - ReferralCount: referralCount, + Code: proj.ReferralCode, + ReferralCount: referralCount, + MaxAllowedRewards: c.Config().BillingManager.MetronomeClient.MaxReferralRewards, } c.WriteResult(w, r, referralCodeResponse) diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 9a64261975..5228fc835a 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -17,13 +17,15 @@ const TrialValidator = z.object({ }); export type Plan = z.infer; -export const PlanValidator = z.object({ - id: z.string(), - plan_name: z.string(), - plan_description: z.string(), - starting_on: z.string(), - trial_info: TrialValidator, -}).nullable(); +export const PlanValidator = z + .object({ + id: z.string(), + plan_name: z.string(), + plan_description: z.string(), + starting_on: z.string(), + trial_info: TrialValidator, + }) + .nullable(); export type UsageMetric = z.infer; export const UsageMetricValidator = z.object({ @@ -52,7 +54,10 @@ export const CreditGrantsValidator = z.object({ export const ClientSecretResponse = z.string(); export type ReferralDetails = z.infer; -export const ReferralDetailsValidator = z.object({ - code: z.string(), - referral_count: z.number(), -}).nullable(); +export const ReferralDetailsValidator = z + .object({ + code: z.string(), + referral_count: z.number(), + max_allowed_referrals: z.number(), + }) + .nullable(); diff --git a/dashboard/src/main/home/project-settings/ReferralsPage.tsx b/dashboard/src/main/home/project-settings/ReferralsPage.tsx index 226ca0b238..2f2a19cef9 100644 --- a/dashboard/src/main/home/project-settings/ReferralsPage.tsx +++ b/dashboard/src/main/home/project-settings/ReferralsPage.tsx @@ -1,35 +1,36 @@ import React from "react"; + +import Link from "components/porter/Link"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { useReferralDetails } from "lib/hooks/useStripe"; -import Link from "components/porter/Link"; function ReferralsPage(): JSX.Element { - const { referralDetails } = useReferralDetails(); - const baseUrl = window.location.origin; + const { referralDetails } = useReferralDetails(); + const baseUrl = window.location.origin; - return ( + return ( + <> + Referrals + + Refer people to Porter to earn credits. + + {referralDetails !== null && ( <> - Referrals - - - Refer people to Porter to earn credits. - - - {referralDetails !== null && ( - <> - - Your referral link is {" "} - - Date: Wed, 1 May 2024 15:56:38 -0400 Subject: [PATCH 16/17] consolidate referral ui --- .../home/project-settings/BillingPage.tsx | 275 +++++++++++------- .../main/home/project-settings/InviteList.tsx | 4 +- .../home/project-settings/ProjectSettings.tsx | 10 - go.sum | 2 - 4 files changed, 171 insertions(+), 120 deletions(-) diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 666090a1f2..623deb15a4 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -1,12 +1,18 @@ import React, { useContext, useMemo, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; import styled from "styled-components"; +import CopyToClipboard from "components/CopyToClipboard"; import Loading from "components/Loading"; +import Banner from "components/porter/Banner"; import Button from "components/porter/Button"; import Container from "components/porter/Container"; import Fieldset from "components/porter/Fieldset"; import Icon from "components/porter/Icon"; import Image from "components/porter/Image"; +import Link from "components/porter/Link"; +import Modal from "components/porter/Modal"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { @@ -15,10 +21,9 @@ import { useCustomerUsage, usePaymentMethods, usePorterCredits, + useReferralDetails, useSetDefaultPaymentMethod, } from "lib/hooks/useStripe"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; import { Context } from "shared/Context"; import cardIcon from "assets/credit-card.svg"; @@ -31,8 +36,10 @@ import Bars from "./Bars"; dayjs.extend(relativeTime); function BillingPage(): JSX.Element { + const { referralDetails } = useReferralDetails(); const { setCurrentOverlay } = useContext(Context); const [shouldCreate, setShouldCreate] = useState(false); + const [showReferralModal, setShowReferralModal] = useState(false); const { currentProject } = useContext(Context); const { creditGrants } = usePorterCredits(); @@ -93,6 +100,16 @@ function BillingPage(): JSX.Element { await refetchPaymentEnabled({ throwOnError: false, cancelRefetch: false }); }; + const isTrialExpired = (timestamp: string): boolean => { + if (timestamp === "") { + return true; + } + const timestampDate = dayjs(timestamp); + return timestampDate.isBefore(dayjs(new Date())); + }; + + const trialExpired = plan && isTrialExpired(plan.trial_info.ending_before); + if (shouldCreate) { return ( + {plan?.trial_info !== undefined && + plan.trial_info.ending_before !== "" && + !trialExpired && ( + <> + + Your free trial is ending{" "} + {dayjs().to(dayjs(plan.trial_info.ending_before))}. + + + + )} + {currentProject?.metronome_enabled && currentProject?.sandbox_enabled && ( + <> + Credit balance + + + View the amount of Porter credits you have remaining to spend on + resources in this project. + + + + + + + {creditGrants && creditGrants.remaining_credits > 0 + ? `$${formatCredits(creditGrants.remaining_credits)}` + : "$ 0.00"} + + + + + Earn additional free credits by{" "} + { + setShowReferralModal(true); + }} + > + referring users to Porter + + . + + + + )} Payment methods @@ -179,116 +241,98 @@ function BillingPage(): JSX.Element { onClick={() => { setShouldCreate(true); }} + alt > add - Add Payment Method + Add payment method - {currentProject?.metronome_enabled && ( -
- - {currentProject?.sandbox_enabled && ( -
- Porter credit grants - + {currentProject?.metronome_enabled && plan && plan.plan_name !== "" ? ( + <> + Current usage + + + View the current usage of this billing period. + + + {usage?.length && + usage.length > 0 && + usage[0].usage_metrics.length > 0 ? ( + + + + + + + + + + ) : ( +
- View the amount of Porter credits you have available to spend on - resources within this project. + No usage data available for this billing period. - - - - - - - {creditGrants && - creditGrants.remaining_credits > 0 - ? `$${formatCredits( - creditGrants.remaining_credits - )}/$${formatCredits(creditGrants.granted_credits)}` - : "$ 0.00"} - - - -
+ )} - -
- Plan Details - - - View the details of the current billing plan of this project. - - - - {plan && plan.plan_name !== "" ? ( -
- Active Plan - -
- - - {plan.plan_name} - - - {plan.trial_info !== undefined && - plan.trial_info.ending_before !== "" ? ( - - Free trial ends{" "} - {dayjs().to(dayjs(plan.trial_info.ending_before))} - - ) : ( - Started on {readableDate(plan.starting_on)} - )} - - -
- - Current Usage - - - View the current usage of this billing period. - - - {usage?.length && - usage.length > 0 && - usage[0].usage_metrics.length > 0 ? ( - - - - - - - - - - ) : ( -
- - No usage data available for this billing period. - -
- )} - -
- ) : ( - This project does not have an active billing plan. - )} -
-
+ + + ) : ( + This project does not have an active billing plan. + )} + {showReferralModal && ( + { + setShowReferralModal(false); + }} + > + Refer users to Porter + + + Earn $10 in free credits for each user you refer to Porter. Referred + users need to connect a payment method for credits to be added to + your account. + + + + + Referral code:{" "} + {currentProject?.referral_code ? ( + {currentProject.referral_code} + ) : ( + "n/a" + )} + + + + Copy referral link + + + + + You have referred{" "} + {referralDetails ? referralDetails.referral_count : "?"}/10 users. + + )} ); @@ -296,6 +340,25 @@ function BillingPage(): JSX.Element { export default BillingPage; +const CopyButton = styled.div` + cursor: pointer; + background: #ffffff11; + padding: 5px; + border-radius: 5px; + font-size: 13px; +`; + +const Code = styled.span` + font-style: italic; +`; + +const ReferralCode = styled.div` + background: linear-gradient(60deg, #4b366d 0%, #6475b9 100%); + padding: 10px 15px; + border-radius: 10px; + width: fit-content; +`; + const Flex = styled.div` display: flex; flex-wrap: wrap; @@ -308,8 +371,8 @@ const BarWrapper = styled.div` `; const I = styled.i` - font-size: 18px; - margin-right: 10px; + font-size: 16px; + margin-right: 8px; `; const DeleteButton = styled.div` diff --git a/dashboard/src/main/home/project-settings/InviteList.tsx b/dashboard/src/main/home/project-settings/InviteList.tsx index 4561c89746..80d6511fb7 100644 --- a/dashboard/src/main/home/project-settings/InviteList.tsx +++ b/dashboard/src/main/home/project-settings/InviteList.tsx @@ -684,8 +684,8 @@ const InvitePage: React.FunctionComponent = ({}) => { export default InvitePage; const I = styled.i` - margin-right: 10px; - font-size: 18px; + margin-right: 8px; + font-size: 16px; `; const Flex = styled.div` diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index d3be68a98d..2f8dd9b9c7 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -26,7 +26,6 @@ import settingsGrad from "assets/settings-grad.svg"; import DashboardHeader from "../cluster-dashboard/DashboardHeader"; import APITokensSection from "./APITokensSection"; import BillingPage from "./BillingPage"; -import ReferralsPage from "./ReferralsPage"; import InvitePage from "./InviteList"; import Metadata from "./Metadata"; import ProjectDeleteConsent from "./ProjectDeleteConsent"; @@ -96,13 +95,6 @@ function ProjectSettings(props: any) { }); } - if (currentProject?.sandbox_enabled && currentProject?.billing_enabled) { - tabOpts.push({ - value: "referrals", - label: "Referrals", - }); - } - tabOpts.push({ value: "additional-settings", label: "Additional settings", @@ -180,8 +172,6 @@ function ProjectSettings(props: any) { return ; } else if (currentTab === "billing") { return ; - } else if (currentTab === "referrals") { - return } else { return ( <> diff --git a/go.sum b/go.sum index 739a537b1e..cc59b871df 100644 --- a/go.sum +++ b/go.sum @@ -1554,8 +1554,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= -github.com/porter-dev/api-contracts v0.2.156 h1:IooB1l6tl+jiGecj2IzYsPoIJxnePaJntDpKSwJBxgc= -github.com/porter-dev/api-contracts v0.2.156/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= github.com/porter-dev/api-contracts v0.2.157 h1:xjC1q4/8ZUl5QLVyCkTfIiMZn+k8h0c9AO9nrCFcZ1Y= github.com/porter-dev/api-contracts v0.2.157/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M= From 7b0fbe2d8019eece6c83f8d3f0ca862d32b51ea0 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 1 May 2024 16:32:44 -0400 Subject: [PATCH 17/17] Make uniqueness key uuid, use max referrals in fe --- .../home/project-settings/BillingPage.tsx | 19 +++---------------- dashboard/src/shared/types.tsx | 13 +++++++------ internal/billing/metronome.go | 5 +---- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 4095904a5b..a02763f6cc 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -57,19 +57,6 @@ function BillingPage(): JSX.Element { const { usage } = useCustomerUsage("day", true); - const trialEnding = (starting_on: string, ending_before: string): string => { - if (ending_before === undefined) { - return ""; - } - - const diff = dayjs(ending_before).diff(dayjs()); - if (diff <= 0) { - return `Started on ${readableDate(starting_on)}`; - } - - return `Free trial ends ${dayjs().to(dayjs(ending_before))}`; - }; - const processedData = useMemo(() => { const before = usage; const resultMap = new Map(); @@ -270,8 +257,8 @@ function BillingPage(): JSX.Element {
{usage?.length && - usage.length > 0 && - usage[0].usage_metrics.length > 0 ? ( + usage.length > 0 && + usage[0].usage_metrics.length > 0 ? ( You have referred{" "} - {referralDetails ? referralDetails.referral_count : "?"}/10 users. + {referralDetails ? referralDetails.referral_count : "?"}/{referralDetails?.max_allowed_referrals} users. )} diff --git a/dashboard/src/shared/types.tsx b/dashboard/src/shared/types.tsx index 30a80a2ade..f623a998a0 100644 --- a/dashboard/src/shared/types.tsx +++ b/dashboard/src/shared/types.tsx @@ -289,15 +289,15 @@ export type FormElement = { export type RepoType = { FullName: string; } & ( - | { + | { Kind: "github"; GHRepoID: number; } - | { + | { Kind: "gitlab"; GitIntegrationId: number; } -); + ); export type FileType = { path: string; @@ -344,6 +344,7 @@ export type ProjectType = { user_id: number; project_id: number; }>; + referral_code: string; }; export type ChoiceType = { @@ -379,15 +380,15 @@ export type ActionConfigType = { image_repo_uri: string; dockerfile_path?: string; } & ( - | { + | { kind: "gitlab"; gitlab_integration_id: number; } - | { + | { kind: "github"; git_repo_id: number; } -); + ); export type GithubActionConfigType = ActionConfigType & { kind: "github"; diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go index fa9f5bc3b4..7b2356c92e 100644 --- a/internal/billing/metronome.go +++ b/internal/billing/metronome.go @@ -272,12 +272,9 @@ func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid 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: uniquenessKey, + UniquenessKey: uuid.NewString(), GrantAmount: types.GrantAmountID{ Amount: grantAmount, CreditTypeID: creditTypeID,