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
This commit is contained in:
Cameron 2023-01-26 13:31:13 -05:00
parent 529e3674e4
commit 8842985571
7 changed files with 182 additions and 4 deletions

View File

@ -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{

View File

@ -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)

View File

@ -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())
})
}
})
}

View File

@ -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()

View File

@ -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)

View File

@ -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
}
}

View File

@ -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