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
This commit is contained in:
Moby von Briesen 2021-06-21 20:09:56 -04:00 committed by Maximillian von Briesen
parent dae6ed7d03
commit 149f6f2626
30 changed files with 287 additions and 93 deletions

2
go.mod
View File

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

5
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,8 @@
<meta name="beta-satellite-feedback-url" content="{{ .BetaSatelliteFeedbackURL }}">
<meta name="beta-satellite-support-url" content="{{ .BetaSatelliteSupportURL }}">
<meta name="documentation-url" content="{{ .DocumentationURL }}">
<meta name="coupon-code-ui-enabled" content="{{ .CouponCodeUIEnabled }}">
<meta name="coupon-code-billing-ui-enabled" content="{{ .CouponCodeBillingUIEnabled }}">
<meta name="coupon-code-signup-ui-enabled" content="{{ .CouponCodeSignupUIEnabled }}">
<meta name="file-browser-flow-disabled" content="{{ .FileBrowserFlowDisabled }}">
<meta name="linksharing-url" content="{{ .LinksharingURL }}">
<meta name="storage-tb-price" content="{{ .StorageTBPrice }}">

View File

@ -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');
}
}

View File

@ -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<void> {
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}"`);
}
}

View File

@ -9,7 +9,7 @@
width="174px"
:on-press="onCreateClick"
label="Add Coupon Code"
v-if="couponCodeUIEnabled"
v-if="couponCodeBillingUIEnabled"
/>
<div class="credit-history__container">
<div class="credit-history__content">
@ -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;
}
}
</script>

View File

@ -22,19 +22,11 @@
class="add-coupon__check"
v-if="isCodeValid"
/>
<VButton
label="Validate"
class="add-coupon__claim-button"
width="120px"
height="32px"
v-if="!isCodeValid"
:on-press="onValidationCheckClick"
/>
</div>
<ValidationMessage
class="add-coupon__valid-message"
successMessage="Get 50GB free and start using Storj DCS today."
errorMessage="Invalid code. Please Try again"
successMessage="Successfully applied coupon code."
:errorMessage="errorMessage"
:isValid="isCodeValid"
:showMessage="showValidationMessage"
/>
@ -44,6 +36,7 @@
width="85%"
height="44px"
v-if="!isSignupView"
:on-press="onApplyClick"
/>
</div>
</template>
@ -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;
}
}
</script>
@ -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;
}
}

View File

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

View File

@ -32,7 +32,8 @@ export const appStateModule = {
satelliteName: '',
partneredSatellites: new Array<PartneredSatellite>(),
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);
},
},
};

View File

@ -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<PaymentsState>
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges);
},
[APPLY_COUPON_CODE]: async function({commit}: any, code: string): Promise<void> {
await api.applyCouponCode(code);
},
},
getters: {
canUserCreateFirstProject: (state: PaymentsState): boolean => {

View File

@ -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',
};

View File

@ -69,6 +69,14 @@ export interface PaymentsApi {
* @throws Error
*/
makeTokenDeposit(amount: number): Promise<TokenDeposit>;
/**
* applyCouponCode applies a coupon code.
*
* @param couponCode
* @throws Error
*/
applyCouponCode(couponCode: string): Promise<void>;
}
export class AccountBalance {

View File

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

View File

@ -151,7 +151,7 @@
is-password="true"
/>
</div>
<AddCouponCodeInput v-if="couponCodeUIEnabled" />
<AddCouponCodeInput v-if="couponCodeSignupUIEnabled" />
<div v-if="isBetaSatellite" class="register-area__input-area__container__warning">
<div class="register-area__input-area__container__warning__header">
<label class="container">
@ -463,8 +463,8 @@ export default class RegisterArea extends Vue {
/**
* Indicates if coupon code ui is enabled
*/
public get couponCodeUIEnabled(): boolean {
return this.$store.state.appStateModule.couponCodeUIEnabled;
public get couponCodeSignupUIEnabled(): boolean {
return this.$store.state.appStateModule.couponCodeSigunpUIEnabled;
}
/**

View File

@ -50,7 +50,7 @@ export class PaymentsMock implements PaymentsApi {
return Promise.resolve(new TokenDeposit(amount, 'testAddress', 'testLink'));
}
getPaywallStatus(userId: string): Promise<boolean> {
applyCouponCode(code: string): Promise<void> {
throw new Error('Method not implemented');
}
}