From 149f6f26265fdc7612b827789e9353d084935c1c Mon Sep 17 00:00:00 2001 From: Moby von Briesen Date: Mon, 21 Jun 2021 20:09:56 -0400 Subject: [PATCH] satellite/payments: Implement coupon codes Full path: satellite/{payments,console},web/satellite * Adds the ability to apply coupon codes from the billing page in the satellite UI. * Flag for coupon code UI is split into two flags - one for the billing page and one for the signup page. This commit implements the first, but not the second. * Update the Stripe dependency to v72, which is necessary to use Stripe's promo code functionality. Change-Id: I19d9815c48205932bef68d87d5cb0b000498fa70 --- go.mod | 2 +- go.sum | 5 +- .../console/consoleweb/consoleapi/payments.go | 22 +++++ satellite/console/consoleweb/server.go | 10 +- satellite/console/service.go | 13 ++- satellite/payments/coupons.go | 3 + .../payments/stripecoinpayments/accounts.go | 2 +- .../payments/stripecoinpayments/client.go | 25 +++-- .../payments/stripecoinpayments/coupons.go | 31 +++++- .../stripecoinpayments/creditcards.go | 2 +- .../payments/stripecoinpayments/invoices.go | 2 +- .../payments/stripecoinpayments/service.go | 2 +- .../payments/stripecoinpayments/stripemock.go | 96 +++++++++++++++---- .../payments/stripecoinpayments/tokens.go | 2 +- .../stripecoinpayments/tokens_test.go | 2 +- .../stripecoinpayments/transactions_test.go | 2 +- scripts/testdata/satellite-config.yaml.lock | 7 +- web/satellite/index.html | 3 +- web/satellite/src/App.vue | 10 +- web/satellite/src/api/payments.ts | 20 ++++ .../billing/freeCredits/CreditsHistory.vue | 8 +- .../components/common/AddCouponCodeInput.vue | 64 +++++++------ .../components/common/ValidationMessage.vue | 3 +- web/satellite/src/store/modules/appState.ts | 17 +++- web/satellite/src/store/modules/payments.ts | 5 + web/satellite/src/store/mutationConstants.ts | 3 +- web/satellite/src/types/payments.ts | 8 ++ .../src/utils/constants/actionNames.ts | 3 +- web/satellite/src/views/RegisterArea.vue | 6 +- web/satellite/tests/unit/mock/api/payments.ts | 2 +- 30 files changed, 287 insertions(+), 93 deletions(-) diff --git a/go.mod b/go.mod index 3a344a910..df3029e4b 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.7.0 - github.com/stripe/stripe-go v70.15.0+incompatible + github.com/stripe/stripe-go/v72 v72.51.0 github.com/vivint/infectious v0.0.0-20200605153912-25a574ae18a3 github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/zeebo/assert v1.3.0 diff --git a/go.sum b/go.sum index 804356021..9216959f2 100644 --- a/go.sum +++ b/go.sum @@ -507,8 +507,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stripe/stripe-go v70.15.0+incompatible h1:hNML7M1zx8RgtepEMlxyu/FpVPrP7KZm1gPFQquJQvM= -github.com/stripe/stripe-go v70.15.0+incompatible/go.mod h1:A1dQZmO/QypXmsL0T8axYZkSN/uA/T/A64pfKdBAMiY= +github.com/stripe/stripe-go/v72 v72.51.0 h1:scXELorHW1SnAfARThO1QayscOsfEIoIAUy0yxoTqxY= +github.com/stripe/stripe-go/v72 v72.51.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= @@ -660,6 +660,7 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= diff --git a/satellite/console/consoleweb/consoleapi/payments.go b/satellite/console/consoleweb/consoleapi/payments.go index 2e6fe1c94..e9a3a2b14 100644 --- a/satellite/console/consoleweb/consoleapi/payments.go +++ b/satellite/console/consoleweb/consoleapi/payments.go @@ -5,6 +5,7 @@ package consoleapi import ( "encoding/json" + "io" "io/ioutil" "net/http" "strconv" @@ -308,6 +309,27 @@ func (p *Payments) TokenDeposit(w http.ResponseWriter, r *http.Request) { } } +// ApplyCouponCode applies a coupon code to the user's account. +func (p *Payments) ApplyCouponCode(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + // limit the size of the body to prevent excessive memory usage + bodyBytes, err := ioutil.ReadAll(io.LimitReader(r.Body, 1*1024*1024)) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + couponCode := string(bodyBytes) + + err = p.service.Payments().ApplyCouponCode(ctx, couponCode) + 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) { serveJSONError(p.log, w, status, err) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 5f3c0f1d8..4512dbcbe 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -84,7 +84,8 @@ type Config struct { BetaSatelliteFeedbackURL string `help:"url link for for beta satellite feedback" default:""` BetaSatelliteSupportURL string `help:"url link for for beta satellite support" default:""` DocumentationURL string `help:"url link to documentation" default:"https://docs.storj.io/"` - CouponCodeUIEnabled bool `help:"indicates if user is allowed to add coupon codes to account" default:"false"` + CouponCodeBillingUIEnabled bool `help:"indicates if user is allowed to add coupon codes to account from billing" default:"false"` + CouponCodeSignupUIEnabled bool `help:"indicates if user is allowed to add coupon codes to account from signup" default:"false"` FileBrowserFlowDisabled bool `help:"indicates if file browser flow is disabled" default:"false"` CSPEnabled bool `help:"indicates if Content Security Policy is enabled" devDefault:"false" releaseDefault:"true"` LinksharingURL string `help:"url link for linksharing requests" default:"https://link.us1.storjshare.io"` @@ -245,6 +246,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail paymentsRouter.HandleFunc("/account", paymentController.SetupAccount).Methods(http.MethodPost) paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet) paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost) + paymentsRouter.HandleFunc("/couponcodes/apply", paymentController.ApplyCouponCode).Methods(http.MethodPatch) bucketsController := consoleapi.NewBuckets(logger, service) bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter() @@ -356,7 +358,8 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { BetaSatelliteFeedbackURL string BetaSatelliteSupportURL string DocumentationURL string - CouponCodeUIEnabled bool + CouponCodeBillingUIEnabled bool + CouponCodeSignupUIEnabled bool FileBrowserFlowDisabled bool LinksharingURL string PathwayOverviewEnabled bool @@ -381,7 +384,8 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { data.BetaSatelliteFeedbackURL = server.config.BetaSatelliteFeedbackURL data.BetaSatelliteSupportURL = server.config.BetaSatelliteSupportURL data.DocumentationURL = server.config.DocumentationURL - data.CouponCodeUIEnabled = server.config.CouponCodeUIEnabled + data.CouponCodeBillingUIEnabled = server.config.CouponCodeBillingUIEnabled + data.CouponCodeSignupUIEnabled = server.config.CouponCodeSignupUIEnabled data.FileBrowserFlowDisabled = server.config.FileBrowserFlowDisabled data.LinksharingURL = server.config.LinksharingURL data.PathwayOverviewEnabled = server.config.PathwayOverviewEnabled diff --git a/satellite/console/service.go b/satellite/console/service.go index c16829fc5..c20a1c352 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -15,7 +15,7 @@ import ( "github.com/spacemonkeygo/monkit/v3" "github.com/spf13/pflag" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "github.com/zeebo/errs" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" @@ -529,6 +529,17 @@ func (paymentService PaymentsService) checkProjectInvoicingStatus(ctx context.Co return paymentService.service.accounts.CheckProjectInvoicingStatus(ctx, projectID) } +// ApplyCouponCode applies a coupon code to a Stripe customer. +func (paymentService PaymentsService) ApplyCouponCode(ctx context.Context, couponCode string) (err error) { + defer mon.Task()(&ctx)(&err) + + auth, err := paymentService.service.getAuthAndAuditLog(ctx, "apply coupon code") + if err != nil { + return Error.Wrap(err) + } + return paymentService.service.accounts.Coupons().ApplyCouponCode(ctx, auth.User.ID, couponCode) +} + // AddPromotionalCoupon creates new coupon for specified user. func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) { defer mon.Task()(&ctx, userID)(&err) diff --git a/satellite/payments/coupons.go b/satellite/payments/coupons.go index c2128a633..3379dfa11 100644 --- a/satellite/payments/coupons.go +++ b/satellite/payments/coupons.go @@ -33,6 +33,9 @@ type Coupons interface { // a project, payment method and do not have a promotional coupon yet. // And updates project limits to selected size. PopulatePromotionalCoupons(ctx context.Context, duration *int, amount int64, projectLimit memory.Size) error + + // ApplyCouponCode attempts to apply a coupon code to the user. + ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) error } // Coupon is an entity that adds some funds to Accounts balance for some fixed period. diff --git a/satellite/payments/stripecoinpayments/accounts.go b/satellite/payments/stripecoinpayments/accounts.go index af7711b68..948fd1d98 100644 --- a/satellite/payments/stripecoinpayments/accounts.go +++ b/satellite/payments/stripecoinpayments/accounts.go @@ -8,7 +8,7 @@ import ( "errors" "time" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "storj.io/common/uuid" "storj.io/storj/satellite/payments" diff --git a/satellite/payments/stripecoinpayments/client.go b/satellite/payments/stripecoinpayments/client.go index c51fae883..fd3cd32d3 100644 --- a/satellite/payments/stripecoinpayments/client.go +++ b/satellite/payments/stripecoinpayments/client.go @@ -4,13 +4,14 @@ package stripecoinpayments import ( - "github.com/stripe/stripe-go" - "github.com/stripe/stripe-go/charge" - "github.com/stripe/stripe-go/client" - "github.com/stripe/stripe-go/customerbalancetransaction" - "github.com/stripe/stripe-go/invoice" - "github.com/stripe/stripe-go/invoiceitem" - "github.com/stripe/stripe-go/paymentmethod" + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/charge" + "github.com/stripe/stripe-go/v72/client" + "github.com/stripe/stripe-go/v72/customerbalancetransaction" + "github.com/stripe/stripe-go/v72/invoice" + "github.com/stripe/stripe-go/v72/invoiceitem" + "github.com/stripe/stripe-go/v72/paymentmethod" + "github.com/stripe/stripe-go/v72/promotioncode" "go.uber.org/zap" ) @@ -22,6 +23,7 @@ type StripeClient interface { InvoiceItems() StripeInvoiceItems CustomerBalanceTransactions() StripeCustomerBalanceTransactions Charges() StripeCharges + PromoCodes() StripePromoCodes } // StripeCustomers Stripe Customers interface. @@ -57,6 +59,11 @@ type StripeCharges interface { List(listParams *stripe.ChargeListParams) *charge.Iter } +// StripePromoCodes is the Stripe PromoCodes interface. +type StripePromoCodes interface { + List(params *stripe.PromotionCodeListParams) *promotioncode.Iter +} + // StripeCustomerBalanceTransactions Stripe CustomerBalanceTransactions interface. type StripeCustomerBalanceTransactions interface { New(params *stripe.CustomerBalanceTransactionParams) (*stripe.CustomerBalanceTransaction, error) @@ -91,6 +98,10 @@ func (s *stripeClient) Charges() StripeCharges { return s.client.Charges } +func (s *stripeClient) PromoCodes() StripePromoCodes { + return s.client.PromotionCodes +} + // NewStripeClient creates Stripe client from configuration. func NewStripeClient(log *zap.Logger, config Config) StripeClient { backendConfig := &stripe.BackendConfig{ diff --git a/satellite/payments/stripecoinpayments/coupons.go b/satellite/payments/stripecoinpayments/coupons.go index 40c1ade75..78f05c52a 100644 --- a/satellite/payments/stripecoinpayments/coupons.go +++ b/satellite/payments/stripecoinpayments/coupons.go @@ -7,7 +7,7 @@ import ( "context" "time" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "storj.io/common/memory" "storj.io/common/uuid" @@ -210,3 +210,32 @@ func (coupons *coupons) AddPromotionalCoupon(ctx context.Context, userID uuid.UU return Error.Wrap(coupons.service.db.Coupons().PopulatePromotionalCoupons(ctx, []uuid.UUID{userID}, couponDuration, coupons.service.CouponValue, coupons.service.CouponProjectLimit)) } + +// ApplyCouponCode attempts to apply a coupon code to the user via Stripe. +func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (err error) { + defer mon.Task()(&ctx, userID, couponCode)(&err) + + promoCodeIter := coupons.service.stripeClient.PromoCodes().List(&stripe.PromotionCodeListParams{ + Code: stripe.String(couponCode), + }) + if !promoCodeIter.Next() { + return Error.New("Invalid coupon code") + } + promoCode := promoCodeIter.PromotionCode() + + customerID, err := coupons.service.db.Customers().GetCustomerID(ctx, userID) + if err != nil { + return Error.Wrap(err) + } + + params := &stripe.CustomerParams{ + PromotionCode: stripe.String(promoCode.ID), + } + + _, err = coupons.service.stripeClient.Customers().Update(customerID, params) + if err != nil { + return Error.Wrap(err) + } + + return nil +} diff --git a/satellite/payments/stripecoinpayments/creditcards.go b/satellite/payments/stripecoinpayments/creditcards.go index 71e9a5064..9156c4b87 100644 --- a/satellite/payments/stripecoinpayments/creditcards.go +++ b/satellite/payments/stripecoinpayments/creditcards.go @@ -6,7 +6,7 @@ package stripecoinpayments import ( "context" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "github.com/zeebo/errs" "storj.io/common/uuid" diff --git a/satellite/payments/stripecoinpayments/invoices.go b/satellite/payments/stripecoinpayments/invoices.go index bab6705b6..190f52486 100644 --- a/satellite/payments/stripecoinpayments/invoices.go +++ b/satellite/payments/stripecoinpayments/invoices.go @@ -7,7 +7,7 @@ import ( "context" "time" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "storj.io/common/uuid" "storj.io/storj/satellite/payments" diff --git a/satellite/payments/stripecoinpayments/service.go b/satellite/payments/stripecoinpayments/service.go index f92e9a072..a91608db3 100644 --- a/satellite/payments/stripecoinpayments/service.go +++ b/satellite/payments/stripecoinpayments/service.go @@ -15,7 +15,7 @@ import ( "github.com/shopspring/decimal" "github.com/spacemonkeygo/monkit/v3" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "github.com/zeebo/errs" "go.uber.org/zap" diff --git a/satellite/payments/stripecoinpayments/stripemock.go b/satellite/payments/stripecoinpayments/stripemock.go index ad38c02fd..7b61c5465 100644 --- a/satellite/payments/stripecoinpayments/stripemock.go +++ b/satellite/payments/stripecoinpayments/stripemock.go @@ -10,13 +10,14 @@ import ( "sync" "time" - "github.com/stripe/stripe-go" - "github.com/stripe/stripe-go/charge" - "github.com/stripe/stripe-go/customerbalancetransaction" - "github.com/stripe/stripe-go/form" - "github.com/stripe/stripe-go/invoice" - "github.com/stripe/stripe-go/invoiceitem" - "github.com/stripe/stripe-go/paymentmethod" + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/charge" + "github.com/stripe/stripe-go/v72/customerbalancetransaction" + "github.com/stripe/stripe-go/v72/form" + "github.com/stripe/stripe-go/v72/invoice" + "github.com/stripe/stripe-go/v72/invoiceitem" + "github.com/stripe/stripe-go/v72/paymentmethod" + "github.com/stripe/stripe-go/v72/promotioncode" "storj.io/common/storj" "storj.io/common/testrand" @@ -48,6 +49,7 @@ type mockStripeState struct { invoiceItems *mockInvoiceItems customerBalanceTransactions *mockCustomerBalanceTransactions charges *mockCharges + promoCodes *mockPromoCodes } type mockStripeClient struct { @@ -56,6 +58,11 @@ type mockStripeClient struct { *mockStripeState } +// mockEmptyQuery is a query with no results. +var mockEmptyQuery = stripe.Query(func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListContainer, error) { + return nil, newListContainer(&stripe.ListMeta{}), nil +}) + // NewStripeMock creates new Stripe client mock. // // A new mock is returned for each unique id. If this method is called multiple @@ -78,6 +85,7 @@ func NewStripeMock(id storj.NodeID, customersDB CustomersDB, usersDB console.Use invoiceItems: &mockInvoiceItems{}, customerBalanceTransactions: newMockCustomerBalanceTransactions(), charges: &mockCharges{}, + promoCodes: &mockPromoCodes{}, } mocks.m[id] = state } @@ -120,6 +128,10 @@ func (m *mockStripeClient) Charges() StripeCharges { return m.charges } +func (m *mockStripeClient) PromoCodes() StripePromoCodes { + return m.promoCodes +} + type mockCustomers struct { customersDB CustomersDB usersDB console.Users @@ -261,12 +273,27 @@ func newMockPaymentMethods() *mockPaymentMethods { } } +// listContainer implements Stripe's ListContainer interface. +type listContainer struct { + listMeta *stripe.ListMeta +} + +func newListContainer(meta *stripe.ListMeta) *listContainer { + return &listContainer{listMeta: meta} +} + +func (c *listContainer) GetListMeta() *stripe.ListMeta { + return c.listMeta +} + func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *paymentmethod.Iter { - listMeta := stripe.ListMeta{ + listMeta := &stripe.ListMeta{ HasMore: false, TotalCount: uint32(len(m.attached)), } - return &paymentmethod.Iter{Iter: stripe.GetIter(nil, func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListMeta, error) { + lc := newListContainer(listMeta) + + query := stripe.Query(func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListContainer, error) { mocks.Lock() defer mocks.Unlock() @@ -280,8 +307,9 @@ func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *p ret[i] = v } - return ret, listMeta, nil - })} + return ret, lc, nil + }) + return &paymentmethod.Iter{Iter: stripe.GetIter(nil, query)} } func (m *mockPaymentMethods) New(params *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) { @@ -355,7 +383,7 @@ func (m *mockInvoices) New(params *stripe.InvoiceParams) (*stripe.Invoice, error } func (m *mockInvoices) List(listParams *stripe.InvoiceListParams) *invoice.Iter { - return &invoice.Iter{Iter: &stripe.Iter{}} + return &invoice.Iter{Iter: stripe.GetIter(listParams, mockEmptyQuery)} } func (m *mockInvoices) FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error) { @@ -370,7 +398,7 @@ func (m *mockInvoiceItems) New(params *stripe.InvoiceItemParams) (*stripe.Invoic } func (m *mockInvoiceItems) List(listParams *stripe.InvoiceItemListParams) *invoiceitem.Iter { - return &invoiceitem.Iter{Iter: &stripe.Iter{}} + return &invoiceitem.Iter{Iter: stripe.GetIter(listParams, mockEmptyQuery)} } type mockCustomerBalanceTransactions struct { @@ -404,7 +432,7 @@ func (m *mockCustomerBalanceTransactions) List(listParams *stripe.CustomerBalanc mocks.Lock() defer mocks.Unlock() - return &customerbalancetransaction.Iter{Iter: stripe.GetIter(listParams, func(p *stripe.Params, b *form.Values) ([]interface{}, stripe.ListMeta, error) { + query := stripe.Query(func(p *stripe.Params, b *form.Values) ([]interface{}, stripe.ListContainer, error) { txs := m.transactions[*listParams.Customer] ret := make([]interface{}, len(txs)) @@ -412,17 +440,49 @@ func (m *mockCustomerBalanceTransactions) List(listParams *stripe.CustomerBalanc ret[i] = v } - listMeta := stripe.ListMeta{ + listMeta := &stripe.ListMeta{ TotalCount: uint32(len(txs)), } - return ret, listMeta, nil - })} + lc := newListContainer(listMeta) + + return ret, lc, nil + }) + + return &customerbalancetransaction.Iter{Iter: stripe.GetIter(listParams, query)} } type mockCharges struct { } func (m *mockCharges) List(listParams *stripe.ChargeListParams) *charge.Iter { - return &charge.Iter{Iter: &stripe.Iter{}} + return &charge.Iter{Iter: stripe.GetIter(listParams, mockEmptyQuery)} +} + +type mockPromoCodes struct { + promoCodes map[string][]*stripe.PromotionCode +} + +func (m *mockPromoCodes) List(params *stripe.PromotionCodeListParams) *promotioncode.Iter { + mocks.Lock() + defer mocks.Unlock() + + query := stripe.Query(func(p *stripe.Params, b *form.Values) ([]interface{}, stripe.ListContainer, error) { + promoCodes := m.promoCodes[*params.Code] + ret := make([]interface{}, len(promoCodes)) + + for i, v := range promoCodes { + ret[i] = v + } + + listMeta := &stripe.ListMeta{ + TotalCount: uint32(len(promoCodes)), + } + + lc := newListContainer(listMeta) + + return ret, lc, nil + }) + + return &promotioncode.Iter{Iter: stripe.GetIter(params, query)} } diff --git a/satellite/payments/stripecoinpayments/tokens.go b/satellite/payments/stripecoinpayments/tokens.go index 02f8c1b6c..fe4cff3e6 100644 --- a/satellite/payments/stripecoinpayments/tokens.go +++ b/satellite/payments/stripecoinpayments/tokens.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "go.uber.org/zap" "storj.io/common/uuid" diff --git a/satellite/payments/stripecoinpayments/tokens_test.go b/satellite/payments/stripecoinpayments/tokens_test.go index e9d3482c9..fd3f10da0 100644 --- a/satellite/payments/stripecoinpayments/tokens_test.go +++ b/satellite/payments/stripecoinpayments/tokens_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/stretchr/testify/require" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "storj.io/common/memory" "storj.io/common/testcontext" diff --git a/satellite/payments/stripecoinpayments/transactions_test.go b/satellite/payments/stripecoinpayments/transactions_test.go index 95b272ff2..59e7664b6 100644 --- a/satellite/payments/stripecoinpayments/transactions_test.go +++ b/satellite/payments/stripecoinpayments/transactions_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stripe/stripe-go" + "github.com/stripe/stripe-go/v72" "github.com/zeebo/errs" "storj.io/common/errs2" diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 58ee59b13..5bf139d63 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -88,8 +88,11 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0 # url link to contacts page # console.contact-info-url: https://forum.storj.io -# indicates if user is allowed to add coupon codes to account -# console.coupon-code-ui-enabled: false +# indicates if user is allowed to add coupon codes to account from billing +# console.coupon-code-billing-ui-enabled: false + +# indicates if user is allowed to add coupon codes to account from signup +# console.coupon-code-signup-ui-enabled: false # indicates if Content Security Policy is enabled # console.csp-enabled: true diff --git a/web/satellite/index.html b/web/satellite/index.html index 32622b8ac..43c3f278a 100644 --- a/web/satellite/index.html +++ b/web/satellite/index.html @@ -16,7 +16,8 @@ - + + diff --git a/web/satellite/src/App.vue b/web/satellite/src/App.vue index 65b5cb4ed..4bc38b4b2 100644 --- a/web/satellite/src/App.vue +++ b/web/satellite/src/App.vue @@ -32,7 +32,8 @@ export default class App extends Vue { const satelliteName = MetaUtils.getMetaContent('satellite-name'); const partneredSatellitesJson = JSON.parse(MetaUtils.getMetaContent('partnered-satellites')); const isBetaSatellite = MetaUtils.getMetaContent('is-beta-satellite'); - const couponCodeUIEnabled = MetaUtils.getMetaContent('coupon-code-ui-enabled'); + const couponCodeBillingUIEnabled = MetaUtils.getMetaContent('coupon-code-billing-ui-enabled'); + const couponCodeSignupUIEnabled = MetaUtils.getMetaContent('coupon-code-signup-ui-enabled'); if (satelliteName) { this.$store.dispatch(APP_STATE_ACTIONS.SET_SATELLITE_NAME, satelliteName); @@ -55,8 +56,11 @@ export default class App extends Vue { this.$store.dispatch(APP_STATE_ACTIONS.SET_SATELLITE_STATUS, isBetaSatellite === 'true'); } - if (couponCodeUIEnabled) { - this.$store.dispatch(APP_STATE_ACTIONS.SET_COUPON_CODE_UI_STATUS, couponCodeUIEnabled === 'true'); + if (couponCodeBillingUIEnabled) { + this.$store.dispatch(APP_STATE_ACTIONS.SET_COUPON_CODE_BILLING_UI_STATUS, couponCodeBillingUIEnabled === 'true'); + } + if (couponCodeSignupUIEnabled) { + this.$store.dispatch(APP_STATE_ACTIONS.SET_COUPON_CODE_SIGNUP_UI_STATUS, couponCodeSignupUIEnabled === 'true'); } } diff --git a/web/satellite/src/api/payments.ts b/web/satellite/src/api/payments.ts index 4c202ee23..bfbcad43b 100644 --- a/web/satellite/src/api/payments.ts +++ b/web/satellite/src/api/payments.ts @@ -253,4 +253,24 @@ export class PaymentsHttpApi implements PaymentsApi { return new TokenDeposit(result.amount, result.address, result.link); } + + /** + * applyCouponCode applies a coupon code. + * + * @param couponCode + * @throws Error + */ + public async applyCouponCode(couponCode: string): Promise { + const path = `${this.ROOT_PATH}/couponcodes/apply`; + const response = await this.client.patch(path, couponCode); + + if (response.ok) { + return; + } + + if (response.status === 401) { + throw new ErrorUnauthorized(); + } + throw new Error(`Can not apply coupon code "${couponCode}"`); + } } diff --git a/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue b/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue index 9dd68a378..57ec6bfc8 100644 --- a/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue +++ b/web/satellite/src/components/account/billing/freeCredits/CreditsHistory.vue @@ -9,7 +9,7 @@ width="174px" :on-press="onCreateClick" label="Add Coupon Code" - v-if="couponCodeUIEnabled" + v-if="couponCodeBillingUIEnabled" />
@@ -93,10 +93,10 @@ export default class CreditsHistory extends Vue { } /** - * Indicates if coupon code ui is enabled + * Indicates if coupon code ui is enabled on the billing page. */ - public get couponCodeUIEnabled(): boolean { - return this.$store.state.appStateModule.couponCodeUIEnabled; + public get couponCodeBillingUIEnabled(): boolean { + return this.$store.state.appStateModule.couponCodeBillingUIEnabled; } } diff --git a/web/satellite/src/components/common/AddCouponCodeInput.vue b/web/satellite/src/components/common/AddCouponCodeInput.vue index 13db0c818..568eed7fb 100644 --- a/web/satellite/src/components/common/AddCouponCodeInput.vue +++ b/web/satellite/src/components/common/AddCouponCodeInput.vue @@ -22,19 +22,11 @@ class="add-coupon__check" v-if="isCodeValid" /> -
@@ -44,6 +36,7 @@ width="85%" height="44px" v-if="!isSignupView" + :on-press="onApplyClick" />
@@ -59,6 +52,11 @@ import CloseIcon from '@/../static/images/common/closeCross.svg'; import CheckIcon from '@/../static/images/common/validCheck.svg'; import { RouteConfig } from '@/router'; +import { PAYMENTS_ACTIONS } from '@/store/modules/payments'; + +const { + APPLY_COUPON_CODE, +} = PAYMENTS_ACTIONS; @Component({ components: { @@ -70,11 +68,12 @@ import { RouteConfig } from '@/router'; }, }) export default class AddCouponCodeInput extends Vue { + @Prop({default: false}) + private showValidationMessage = false; + private errorMessage = ''; + private isCodeValid = false; - @Prop({default: false}) - protected readonly isCodeValid: boolean; - @Prop({default: false}) - protected readonly showValidationMessage: boolean; + private couponCode = ''; /** * Signup view requires some unque styling and element text. @@ -91,13 +90,27 @@ export default class AddCouponCodeInput extends Vue { return this.isSignupView ? 'Add Coupon' : ''; } - /** - * Check if coupon code is valid - */ - public onValidationCheckClick(): boolean { - return true; + public setCouponCode(value: string): void { + this.couponCode = value; } + /** + * Check if coupon code is valid + */ + public async onApplyClick() { + try { + await this.$store.dispatch(APPLY_COUPON_CODE, this.couponCode); + } catch (error) { + + this.errorMessage = error.message; + this.isCodeValid = false; + this.showValidationMessage = true; + + return; + } + this.isCodeValid = true; + this.showValidationMessage = true; + } } @@ -118,15 +131,6 @@ export default class AddCouponCodeInput extends Vue { position: relative; } - &__claim-button { - position: absolute; - right: 12px; - bottom: 8px; - font-size: 14px; - padding: 2px 0; - z-index: 23; - } - &__valid-message { position: relative; top: 15px; @@ -138,10 +142,10 @@ export default class AddCouponCodeInput extends Vue { right: 0; margin: 0 auto; bottom: 40px; - background: #93a1af; + background: #2683ff; &:hover { - background: darken(#93a1af, 10%); + background: #0059d0; } } diff --git a/web/satellite/src/components/common/ValidationMessage.vue b/web/satellite/src/components/common/ValidationMessage.vue index ca6161869..95e5f2693 100644 --- a/web/satellite/src/components/common/ValidationMessage.vue +++ b/web/satellite/src/components/common/ValidationMessage.vue @@ -40,9 +40,8 @@ export default class ValidationMessage extends Vue { &__wrapper { box-sizing: border-box; border-radius: 6px; - height: 52px; width: 100%; - padding-left: 25px; + padding: 12px 25px; } &__text { diff --git a/web/satellite/src/store/modules/appState.ts b/web/satellite/src/store/modules/appState.ts index 49f936dad..df3a95d3b 100644 --- a/web/satellite/src/store/modules/appState.ts +++ b/web/satellite/src/store/modules/appState.ts @@ -32,7 +32,8 @@ export const appStateModule = { satelliteName: '', partneredSatellites: new Array(), isBetaSatellite: false, - couponCodeUIEnabled: false, + couponCodeBillingUIEnabled: false, + couponCodeSignupUIEnabled: false, }, mutations: { // Mutation changing add projectMembers members popup visibility @@ -126,8 +127,11 @@ export const appStateModule = { [APP_STATE_MUTATIONS.SET_SATELLITE_STATUS](state: any, isBetaSatellite: boolean): void { state.isBetaSatellite = isBetaSatellite; }, - [APP_STATE_MUTATIONS.SET_COUPON_CODE_UI_STATUS](state: any, couponCodeUIEnabled: boolean): void { - state.couponCodeUIEnabled = couponCodeUIEnabled; + [APP_STATE_MUTATIONS.SET_COUPON_CODE_BILLING_UI_STATUS](state: any, couponCodeBillingUIEnabled: boolean): void { + state.couponCodeBillingUIEnabled = couponCodeBillingUIEnabled; + }, + [APP_STATE_MUTATIONS.SET_COUPON_CODE_SIGNUP_UI_STATUS](state: any, couponCodeSignupUIEnabled: boolean): void { + state.couponCodeSignupUIEnabled = couponCodeSignupUIEnabled; }, }, actions: { @@ -261,8 +265,11 @@ export const appStateModule = { [APP_STATE_ACTIONS.SET_SATELLITE_STATUS]: function ({commit}: any, isBetaSatellite: boolean): void { commit(APP_STATE_MUTATIONS.SET_SATELLITE_STATUS, isBetaSatellite); }, - [APP_STATE_ACTIONS.SET_COUPON_CODE_UI_STATUS]: function ({commit}: any, couponCodeUIEnabled: boolean): void { - commit(APP_STATE_MUTATIONS.SET_COUPON_CODE_UI_STATUS, couponCodeUIEnabled); + [APP_STATE_ACTIONS.SET_COUPON_CODE_BILLING_UI_STATUS]: function ({commit}: any, couponCodeBillingUIEnabled: boolean): void { + commit(APP_STATE_MUTATIONS.SET_COUPON_CODE_BILLING_UI_STATUS, couponCodeBillingUIEnabled); + }, + [APP_STATE_ACTIONS.SET_COUPON_CODE_SIGNUP_UI_STATUS]: function ({commit}: any, couponCodeSignupUIEnabled: boolean): void { + commit(APP_STATE_MUTATIONS.SET_COUPON_CODE_SIGNUP_UI_STATUS, couponCodeSignupUIEnabled); }, }, }; diff --git a/web/satellite/src/store/modules/payments.ts b/web/satellite/src/store/modules/payments.ts index 0463ecbd5..f3d6cf2dd 100644 --- a/web/satellite/src/store/modules/payments.ts +++ b/web/satellite/src/store/modules/payments.ts @@ -45,6 +45,7 @@ export const PAYMENTS_ACTIONS = { GET_PROJECT_USAGE_AND_CHARGES: 'getProjectUsageAndCharges', GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP: 'getProjectUsageAndChargesCurrentRollup', GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP: 'getProjectUsageAndChargesPreviousRollup', + APPLY_COUPON_CODE: 'applyCouponCode', }; const { @@ -75,6 +76,7 @@ const { MAKE_TOKEN_DEPOSIT, GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP, GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP, + APPLY_COUPON_CODE, } = PAYMENTS_ACTIONS; export class PaymentsState { @@ -252,6 +254,9 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges); commit(SET_PRICE_SUMMARY, usageAndCharges); }, + [APPLY_COUPON_CODE]: async function({commit}: any, code: string): Promise { + await api.applyCouponCode(code); + }, }, getters: { canUserCreateFirstProject: (state: PaymentsState): boolean => { diff --git a/web/satellite/src/store/mutationConstants.ts b/web/satellite/src/store/mutationConstants.ts index 28fceda9e..a0274bc2f 100644 --- a/web/satellite/src/store/mutationConstants.ts +++ b/web/satellite/src/store/mutationConstants.ts @@ -34,5 +34,6 @@ export const APP_STATE_MUTATIONS = { SET_SATELLITE_NAME: 'SET_SATELLITE_NAME', SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES', SET_SATELLITE_STATUS: 'SET_SATELLITE_STATUS', - SET_COUPON_CODE_UI_STATUS: 'SET_COUPON_CODE_UI_STATUS', + SET_COUPON_CODE_BILLING_UI_STATUS: 'SET_COUPON_CODE_BILLING_UI_STATUS', + SET_COUPON_CODE_SIGNUP_UI_STATUS: 'SET_COUPON_CODE_SIGNUP_UI_STATUS', }; diff --git a/web/satellite/src/types/payments.ts b/web/satellite/src/types/payments.ts index c758236a8..d2ec61061 100644 --- a/web/satellite/src/types/payments.ts +++ b/web/satellite/src/types/payments.ts @@ -69,6 +69,14 @@ export interface PaymentsApi { * @throws Error */ makeTokenDeposit(amount: number): Promise; + + /** + * applyCouponCode applies a coupon code. + * + * @param couponCode + * @throws Error + */ + applyCouponCode(couponCode: string): Promise; } export class AccountBalance { diff --git a/web/satellite/src/utils/constants/actionNames.ts b/web/satellite/src/utils/constants/actionNames.ts index 55297a3f1..e1f181044 100644 --- a/web/satellite/src/utils/constants/actionNames.ts +++ b/web/satellite/src/utils/constants/actionNames.ts @@ -28,7 +28,8 @@ export const APP_STATE_ACTIONS = { SET_SATELLITE_NAME: 'SET_SATELLITE_NAME', SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES', SET_SATELLITE_STATUS: 'SET_SATELLITE_STATUS', - SET_COUPON_CODE_UI_STATUS: 'SET_COUPON_CODE_UI_STATUS', + SET_COUPON_CODE_BILLING_UI_STATUS: 'SET_COUPON_CODE_BILLING_UI_STATUS', + SET_COUPON_CODE_SIGNUP_UI_STATUS: 'SET_COUPON_CODE_SIGNUP_UI_STATUS', }; export const NOTIFICATION_ACTIONS = { diff --git a/web/satellite/src/views/RegisterArea.vue b/web/satellite/src/views/RegisterArea.vue index 2eaf7676b..a41fa3e56 100644 --- a/web/satellite/src/views/RegisterArea.vue +++ b/web/satellite/src/views/RegisterArea.vue @@ -151,7 +151,7 @@ is-password="true" /> - +