From 8842985571d41d960d274c4251785942860c115c Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 26 Jan 2023 13:31:13 -0500 Subject: [PATCH] satellite/console/consoleweb: create purchase-package endpoint Add new purchase-package endpoint to Server. The endpoint can be enabled or disabled by a new config, --console.pricing-packages-enabled. The purchase-package endpoint applies a coupon and adds and charges a credit card if user's useragent is a partner with a configured package plan. github issue: https://github.com/storj/storj-private/issues/125 Change-Id: I0d6ccccd6874ddba360c45f338fd1c44f95e135a --- satellite/api.go | 1 + .../console/consoleweb/consoleapi/payments.go | 56 ++++++++- .../consoleweb/consoleapi/payments_test.go | 110 ++++++++++++++++++ satellite/console/consoleweb/server.go | 12 +- satellite/console/service.go | 2 +- .../payments/stripecoinpayments/stripemock.go | 2 + scripts/testdata/satellite-config.yaml.lock | 3 + 7 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 satellite/console/consoleweb/consoleapi/payments_test.go diff --git a/satellite/api.go b/satellite/api.go index b945002f4..400564286 100644 --- a/satellite/api.go +++ b/satellite/api.go @@ -633,6 +633,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB, peer.Console.Listener, config.Payments.StripeCoinPayments.StripePublicKey, peer.URL(), + config.Payments.PackagePlans, ) peer.Servers.Add(lifecycle.Item{ diff --git a/satellite/console/consoleweb/consoleapi/payments.go b/satellite/console/consoleweb/consoleapi/payments.go index 9c1b44f1f..98def2938 100644 --- a/satellite/console/consoleweb/consoleapi/payments.go +++ b/satellite/console/consoleweb/consoleapi/payments.go @@ -6,6 +6,7 @@ package consoleapi import ( "context" "encoding/json" + "fmt" "io" "net/http" "strconv" @@ -20,6 +21,7 @@ import ( "storj.io/storj/satellite/console" "storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments/billing" + "storj.io/storj/satellite/payments/paymentsconfig" ) var ( @@ -33,14 +35,16 @@ type Payments struct { log *zap.Logger service *console.Service accountFreezeService *console.AccountFreezeService + packagePlans paymentsconfig.PackagePlans } // NewPayments is a constructor for api payments controller. -func NewPayments(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService) *Payments { +func NewPayments(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, packagePlans paymentsconfig.PackagePlans) *Payments { return &Payments{ log: log, service: service, accountFreezeService: accountFreezeService, + packagePlans: packagePlans, } } @@ -463,6 +467,56 @@ func (p *Payments) GetProjectUsagePriceModel(w http.ResponseWriter, r *http.Requ } } +// PurchasePackage purchases one of the configured paymentsconfig.PackagePlans. +func (p *Payments) PurchasePackage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + p.serveJSONError(w, http.StatusBadRequest, err) + return + } + + token := string(bodyBytes) + + u, err := console.GetUser(ctx) + if err != nil { + p.serveJSONError(w, http.StatusUnauthorized, err) + return + } + + pkg, err := p.packagePlans.Get(u.UserAgent) + if err != nil { + p.serveJSONError(w, http.StatusNotFound, err) + return + } + + _, err = p.service.Payments().ApplyCoupon(ctx, pkg.CouponID) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + card, err := p.service.Payments().AddCreditCard(ctx, token) + if err != nil { + switch { + case console.ErrUnauthorized.Has(err): + p.serveJSONError(w, http.StatusUnauthorized, err) + default: + p.serveJSONError(w, http.StatusInternalServerError, err) + } + return + } + + err = p.service.Payments().Purchase(ctx, pkg.Price, fmt.Sprintf("%s package plan", string(u.UserAgent)), card.ID) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } +} + // serveJSONError writes JSON error to response output stream. func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) { web.ServeJSONError(p.log, w, status, err) diff --git a/satellite/console/consoleweb/consoleapi/payments_test.go b/satellite/console/consoleweb/consoleapi/payments_test.go new file mode 100644 index 000000000..effbcb7d9 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/payments_test.go @@ -0,0 +1,110 @@ +// Copyright (C) 2023 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "storj.io/common/testcontext" + "storj.io/storj/private/testplanet" + "storj.io/storj/satellite" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/payments" + "storj.io/storj/satellite/payments/stripecoinpayments" +) + +func Test_PurchasePackage(t *testing.T) { + partner := "partner1" + partner2 := "partner2" + + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index int, config *satellite.Config) { + config.Console.OpenRegistrationEnabled = true + config.Console.RateLimit.Burst = 10 + config.Payments.StripeCoinPayments.StripeFreeTierCouponID = stripecoinpayments.MockCouponID1 + config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{ + partner: {CouponID: stripecoinpayments.MockCouponID2, Price: 1000}, + partner2: {CouponID: "invalidCouponID", Price: 1000}, + } + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + validCardToken := "testValidCardToken" + + tests := []struct { + name, cardToken, partner string + expectedStatus int + }{ + { + // "unknownPartner" does not exist in paymentsconfig.PackagePlans + "No matching package plan for partner", validCardToken, "unknownPartner", + http.StatusNotFound, + }, + { + // partner2's coupon ID configured above in Reconfigure does not exist in underlying + // stipe mock coupons list. + "Coupon doesn't exist", validCardToken, partner2, + http.StatusInternalServerError, + }, + { + "Add credit card fails", stripecoinpayments.TestPaymentMethodsNewFailure, partner, + http.StatusInternalServerError, + }, + { + "Purchase fails", stripecoinpayments.MockInvoicesPayFailure, partner, + http.StatusInternalServerError, + }, + { + "Success", validCardToken, partner, + http.StatusOK, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := sat.AddUser(ctx, console.CreateUser{ + FullName: "test_name", + ShortName: "", + Email: fmt.Sprintf("test%d@storj.test", i), + UserAgent: []byte(tt.partner), + }, 1) + require.NoError(t, err) + + userCtx, err := sat.UserContext(ctx, user.ID) + require.NoError(t, err) + + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(userCtx, "POST", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/payments/purchase-package", strings.NewReader(tt.cardToken)) + require.NoError(t, err) + + expire := time.Now().AddDate(0, 0, 1) + cookie := http.Cookie{ + Name: "_tokenKey", + Path: "/", + Value: tokenInfo.Token.String(), + Expires: expire, + } + + req.AddCookie(&cookie) + + client := http.Client{} + result, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, tt.expectedStatus, result.StatusCode) + require.NoError(t, result.Body.Close()) + }) + } + }) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index b53e5c256..e978ce962 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -42,6 +42,7 @@ import ( "storj.io/storj/satellite/console/consoleweb/consolewebauth" "storj.io/storj/satellite/mailservice" "storj.io/storj/satellite/oidc" + "storj.io/storj/satellite/payments/paymentsconfig" ) const ( @@ -98,6 +99,7 @@ type Config struct { OptionalSignupSuccessURL string `help:"optional url to external registration success page" default:""` HomepageURL string `help:"url link to storj.io homepage" default:"https://www.storj.io"` NativeTokenPaymentsEnabled bool `help:"indicates if storj native token payments system is enabled" default:"false"` + PricingPackagesEnabled bool `help:"whether to allow purchasing pricing packages" default:"false" devDefault:"true"` OauthCodeExpiry time.Duration `help:"how long oauth authorization codes are issued for" default:"10m"` OauthAccessTokenExpiry time.Duration `help:"how long oauth access tokens are issued for" default:"24h"` @@ -132,6 +134,8 @@ type Server struct { stripePublicKey string + packagePlans paymentsconfig.PackagePlans + schema graphql.Schema templatesCache *templates @@ -201,7 +205,7 @@ func (a *apiAuth) RemoveAuthCookie(w http.ResponseWriter) { } // NewServer creates new instance of console server. -func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, analytics *analytics.Service, abTesting *abtesting.Service, accountFreezeService *console.AccountFreezeService, listener net.Listener, stripePublicKey string, nodeURL storj.NodeURL) *Server { +func NewServer(logger *zap.Logger, config Config, service *console.Service, oidcService *oidc.Service, mailService *mailservice.Service, analytics *analytics.Service, abTesting *abtesting.Service, accountFreezeService *console.AccountFreezeService, listener net.Listener, stripePublicKey string, nodeURL storj.NodeURL, packagePlans paymentsconfig.PackagePlans) *Server { server := Server{ log: logger, config: config, @@ -214,6 +218,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc ipRateLimiter: web.NewIPRateLimiter(config.RateLimit, logger), userIDRateLimiter: NewUserIDRateLimiter(config.RateLimit, logger), nodeURL: nodeURL, + packagePlans: packagePlans, } logger.Debug("Starting Satellite UI.", zap.Stringer("Address", server.listener.Addr())) @@ -299,7 +304,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc abRouter.Handle("/hit/{action}", server.withAuth(http.HandlerFunc(abController.SendHit))).Methods(http.MethodPost) } - paymentController := consoleapi.NewPayments(logger, service, accountFreezeService) + paymentController := consoleapi.NewPayments(logger, service, accountFreezeService, packagePlans) paymentsRouter := router.PathPrefix("/api/v0/payments").Subrouter() paymentsRouter.Use(server.withAuth) paymentsRouter.Handle("/cards", server.userIDRateLimiter.Limit(http.HandlerFunc(paymentController.AddCreditCard))).Methods(http.MethodPost) @@ -316,6 +321,9 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc paymentsRouter.Handle("/coupon/apply", server.userIDRateLimiter.Limit(http.HandlerFunc(paymentController.ApplyCouponCode))).Methods(http.MethodPatch) paymentsRouter.HandleFunc("/coupon", paymentController.GetCoupon).Methods(http.MethodGet) paymentsRouter.HandleFunc("/pricing", paymentController.GetProjectUsagePriceModel).Methods(http.MethodGet) + if config.PricingPackagesEnabled { + paymentsRouter.HandleFunc("/purchase-package", paymentController.PurchasePackage).Methods(http.MethodPost) + } bucketsController := consoleapi.NewBuckets(logger, service) bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter() diff --git a/satellite/console/service.go b/satellite/console/service.go index 181bc7673..0232fce58 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -3089,7 +3089,7 @@ func (payment Payments) WalletPayments(ctx context.Context) (_ WalletPayments, e }, nil } -// Purchase makes a purchase of `price` amount with payment method with id of `paymentMethodID`. +// Purchase makes a purchase of `price` amount with description of `desc` and payment method with id of `paymentMethodID`. func (payment Payments) Purchase(ctx context.Context, price int64, desc string, paymentMethodID string) (err error) { defer mon.Task()(&ctx)(&err) diff --git a/satellite/payments/stripecoinpayments/stripemock.go b/satellite/payments/stripecoinpayments/stripemock.go index 2f12a3425..4d9774eaa 100644 --- a/satellite/payments/stripecoinpayments/stripemock.go +++ b/satellite/payments/stripecoinpayments/stripemock.go @@ -418,6 +418,8 @@ func (m *mockPaymentMethods) New(params *stripe.PaymentMethodParams) (*stripe.Pa return nil, &stripe.Error{} case TestPaymentMethodsAttachFailure: id = TestPaymentMethodsAttachFailure + case MockInvoicesPayFailure: + id = MockInvoicesPayFailure } } diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 01782d308..120a95cad 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -280,6 +280,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0 # indicates if the overview onboarding step should render with pathways # console.pathway-overview-enabled: true +# whether to allow purchasing pricing packages +# console.pricing-packages-enabled: false + # url link to project limit increase request page # console.project-limits-increase-request-url: https://supportdcs.storj.io/hc/en-us/requests/new?ticket_form_id=360000683212