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/pflag v1.0.5
github.com/spf13/viper v1.7.1 github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.7.0 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/vivint/infectious v0.0.0-20200605153912-25a574ae18a3
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
github.com/zeebo/assert v1.3.0 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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/v72 v72.51.0 h1:scXELorHW1SnAfARThO1QayscOsfEIoIAUy0yxoTqxY=
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/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= 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/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 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-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-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-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-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-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=

View File

@ -5,6 +5,7 @@ package consoleapi
import ( import (
"encoding/json" "encoding/json"
"io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"strconv" "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. // serveJSONError writes JSON error to response output stream.
func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) { func (p *Payments) serveJSONError(w http.ResponseWriter, status int, err error) {
serveJSONError(p.log, w, status, err) 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:""` BetaSatelliteFeedbackURL string `help:"url link for for beta satellite feedback" default:""`
BetaSatelliteSupportURL string `help:"url link for for beta satellite support" default:""` BetaSatelliteSupportURL string `help:"url link for for beta satellite support" default:""`
DocumentationURL string `help:"url link to documentation" default:"https://docs.storj.io/"` 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"` 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"` 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"` 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("/account", paymentController.SetupAccount).Methods(http.MethodPost)
paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet) paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet)
paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost) paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost)
paymentsRouter.HandleFunc("/couponcodes/apply", paymentController.ApplyCouponCode).Methods(http.MethodPatch)
bucketsController := consoleapi.NewBuckets(logger, service) bucketsController := consoleapi.NewBuckets(logger, service)
bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter() bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter()
@ -356,7 +358,8 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
BetaSatelliteFeedbackURL string BetaSatelliteFeedbackURL string
BetaSatelliteSupportURL string BetaSatelliteSupportURL string
DocumentationURL string DocumentationURL string
CouponCodeUIEnabled bool CouponCodeBillingUIEnabled bool
CouponCodeSignupUIEnabled bool
FileBrowserFlowDisabled bool FileBrowserFlowDisabled bool
LinksharingURL string LinksharingURL string
PathwayOverviewEnabled bool PathwayOverviewEnabled bool
@ -381,7 +384,8 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) {
data.BetaSatelliteFeedbackURL = server.config.BetaSatelliteFeedbackURL data.BetaSatelliteFeedbackURL = server.config.BetaSatelliteFeedbackURL
data.BetaSatelliteSupportURL = server.config.BetaSatelliteSupportURL data.BetaSatelliteSupportURL = server.config.BetaSatelliteSupportURL
data.DocumentationURL = server.config.DocumentationURL 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.FileBrowserFlowDisabled = server.config.FileBrowserFlowDisabled
data.LinksharingURL = server.config.LinksharingURL data.LinksharingURL = server.config.LinksharingURL
data.PathwayOverviewEnabled = server.config.PathwayOverviewEnabled data.PathwayOverviewEnabled = server.config.PathwayOverviewEnabled

View File

@ -15,7 +15,7 @@ import (
"github.com/spacemonkeygo/monkit/v3" "github.com/spacemonkeygo/monkit/v3"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/zeebo/errs" "github.com/zeebo/errs"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -529,6 +529,17 @@ func (paymentService PaymentsService) checkProjectInvoicingStatus(ctx context.Co
return paymentService.service.accounts.CheckProjectInvoicingStatus(ctx, projectID) 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. // AddPromotionalCoupon creates new coupon for specified user.
func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) { func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx, userID)(&err) 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. // a project, payment method and do not have a promotional coupon yet.
// And updates project limits to selected size. // And updates project limits to selected size.
PopulatePromotionalCoupons(ctx context.Context, duration *int, amount int64, projectLimit memory.Size) error 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. // Coupon is an entity that adds some funds to Accounts balance for some fixed period.

View File

@ -8,7 +8,7 @@ import (
"errors" "errors"
"time" "time"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"storj.io/common/uuid" "storj.io/common/uuid"
"storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments"

View File

@ -4,13 +4,14 @@
package stripecoinpayments package stripecoinpayments
import ( import (
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/charge" "github.com/stripe/stripe-go/v72/charge"
"github.com/stripe/stripe-go/client" "github.com/stripe/stripe-go/v72/client"
"github.com/stripe/stripe-go/customerbalancetransaction" "github.com/stripe/stripe-go/v72/customerbalancetransaction"
"github.com/stripe/stripe-go/invoice" "github.com/stripe/stripe-go/v72/invoice"
"github.com/stripe/stripe-go/invoiceitem" "github.com/stripe/stripe-go/v72/invoiceitem"
"github.com/stripe/stripe-go/paymentmethod" "github.com/stripe/stripe-go/v72/paymentmethod"
"github.com/stripe/stripe-go/v72/promotioncode"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -22,6 +23,7 @@ type StripeClient interface {
InvoiceItems() StripeInvoiceItems InvoiceItems() StripeInvoiceItems
CustomerBalanceTransactions() StripeCustomerBalanceTransactions CustomerBalanceTransactions() StripeCustomerBalanceTransactions
Charges() StripeCharges Charges() StripeCharges
PromoCodes() StripePromoCodes
} }
// StripeCustomers Stripe Customers interface. // StripeCustomers Stripe Customers interface.
@ -57,6 +59,11 @@ type StripeCharges interface {
List(listParams *stripe.ChargeListParams) *charge.Iter 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. // StripeCustomerBalanceTransactions Stripe CustomerBalanceTransactions interface.
type StripeCustomerBalanceTransactions interface { type StripeCustomerBalanceTransactions interface {
New(params *stripe.CustomerBalanceTransactionParams) (*stripe.CustomerBalanceTransaction, error) New(params *stripe.CustomerBalanceTransactionParams) (*stripe.CustomerBalanceTransaction, error)
@ -91,6 +98,10 @@ func (s *stripeClient) Charges() StripeCharges {
return s.client.Charges return s.client.Charges
} }
func (s *stripeClient) PromoCodes() StripePromoCodes {
return s.client.PromotionCodes
}
// NewStripeClient creates Stripe client from configuration. // NewStripeClient creates Stripe client from configuration.
func NewStripeClient(log *zap.Logger, config Config) StripeClient { func NewStripeClient(log *zap.Logger, config Config) StripeClient {
backendConfig := &stripe.BackendConfig{ backendConfig := &stripe.BackendConfig{

View File

@ -7,7 +7,7 @@ import (
"context" "context"
"time" "time"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"storj.io/common/memory" "storj.io/common/memory"
"storj.io/common/uuid" "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)) 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 ( import (
"context" "context"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/zeebo/errs" "github.com/zeebo/errs"
"storj.io/common/uuid" "storj.io/common/uuid"

View File

@ -7,7 +7,7 @@ import (
"context" "context"
"time" "time"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"storj.io/common/uuid" "storj.io/common/uuid"
"storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments"

View File

@ -15,7 +15,7 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/spacemonkeygo/monkit/v3" "github.com/spacemonkeygo/monkit/v3"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/zeebo/errs" "github.com/zeebo/errs"
"go.uber.org/zap" "go.uber.org/zap"

View File

@ -10,13 +10,14 @@ import (
"sync" "sync"
"time" "time"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/charge" "github.com/stripe/stripe-go/v72/charge"
"github.com/stripe/stripe-go/customerbalancetransaction" "github.com/stripe/stripe-go/v72/customerbalancetransaction"
"github.com/stripe/stripe-go/form" "github.com/stripe/stripe-go/v72/form"
"github.com/stripe/stripe-go/invoice" "github.com/stripe/stripe-go/v72/invoice"
"github.com/stripe/stripe-go/invoiceitem" "github.com/stripe/stripe-go/v72/invoiceitem"
"github.com/stripe/stripe-go/paymentmethod" "github.com/stripe/stripe-go/v72/paymentmethod"
"github.com/stripe/stripe-go/v72/promotioncode"
"storj.io/common/storj" "storj.io/common/storj"
"storj.io/common/testrand" "storj.io/common/testrand"
@ -48,6 +49,7 @@ type mockStripeState struct {
invoiceItems *mockInvoiceItems invoiceItems *mockInvoiceItems
customerBalanceTransactions *mockCustomerBalanceTransactions customerBalanceTransactions *mockCustomerBalanceTransactions
charges *mockCharges charges *mockCharges
promoCodes *mockPromoCodes
} }
type mockStripeClient struct { type mockStripeClient struct {
@ -56,6 +58,11 @@ type mockStripeClient struct {
*mockStripeState *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. // NewStripeMock creates new Stripe client mock.
// //
// A new mock is returned for each unique id. If this method is called multiple // 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{}, invoiceItems: &mockInvoiceItems{},
customerBalanceTransactions: newMockCustomerBalanceTransactions(), customerBalanceTransactions: newMockCustomerBalanceTransactions(),
charges: &mockCharges{}, charges: &mockCharges{},
promoCodes: &mockPromoCodes{},
} }
mocks.m[id] = state mocks.m[id] = state
} }
@ -120,6 +128,10 @@ func (m *mockStripeClient) Charges() StripeCharges {
return m.charges return m.charges
} }
func (m *mockStripeClient) PromoCodes() StripePromoCodes {
return m.promoCodes
}
type mockCustomers struct { type mockCustomers struct {
customersDB CustomersDB customersDB CustomersDB
usersDB console.Users 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 { func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *paymentmethod.Iter {
listMeta := stripe.ListMeta{ listMeta := &stripe.ListMeta{
HasMore: false, HasMore: false,
TotalCount: uint32(len(m.attached)), 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() mocks.Lock()
defer mocks.Unlock() defer mocks.Unlock()
@ -280,8 +307,9 @@ func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *p
ret[i] = v 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) { 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 { 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) { 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 { 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 { type mockCustomerBalanceTransactions struct {
@ -404,7 +432,7 @@ func (m *mockCustomerBalanceTransactions) List(listParams *stripe.CustomerBalanc
mocks.Lock() mocks.Lock()
defer mocks.Unlock() 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] txs := m.transactions[*listParams.Customer]
ret := make([]interface{}, len(txs)) ret := make([]interface{}, len(txs))
@ -412,17 +440,49 @@ func (m *mockCustomerBalanceTransactions) List(listParams *stripe.CustomerBalanc
ret[i] = v ret[i] = v
} }
listMeta := stripe.ListMeta{ listMeta := &stripe.ListMeta{
TotalCount: uint32(len(txs)), 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 { type mockCharges struct {
} }
func (m *mockCharges) List(listParams *stripe.ChargeListParams) *charge.Iter { 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" "strings"
"time" "time"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"go.uber.org/zap" "go.uber.org/zap"
"storj.io/common/uuid" "storj.io/common/uuid"

View File

@ -10,7 +10,7 @@ import (
"time" "time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"storj.io/common/memory" "storj.io/common/memory"
"storj.io/common/testcontext" "storj.io/common/testcontext"

View File

@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go" "github.com/stripe/stripe-go/v72"
"github.com/zeebo/errs" "github.com/zeebo/errs"
"storj.io/common/errs2" "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 # url link to contacts page
# console.contact-info-url: https://forum.storj.io # console.contact-info-url: https://forum.storj.io
# indicates if user is allowed to add coupon codes to account # indicates if user is allowed to add coupon codes to account from billing
# console.coupon-code-ui-enabled: false # 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 # indicates if Content Security Policy is enabled
# console.csp-enabled: true # console.csp-enabled: true

View File

@ -16,7 +16,8 @@
<meta name="beta-satellite-feedback-url" content="{{ .BetaSatelliteFeedbackURL }}"> <meta name="beta-satellite-feedback-url" content="{{ .BetaSatelliteFeedbackURL }}">
<meta name="beta-satellite-support-url" content="{{ .BetaSatelliteSupportURL }}"> <meta name="beta-satellite-support-url" content="{{ .BetaSatelliteSupportURL }}">
<meta name="documentation-url" content="{{ .DocumentationURL }}"> <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="file-browser-flow-disabled" content="{{ .FileBrowserFlowDisabled }}">
<meta name="linksharing-url" content="{{ .LinksharingURL }}"> <meta name="linksharing-url" content="{{ .LinksharingURL }}">
<meta name="storage-tb-price" content="{{ .StorageTBPrice }}"> <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 satelliteName = MetaUtils.getMetaContent('satellite-name');
const partneredSatellitesJson = JSON.parse(MetaUtils.getMetaContent('partnered-satellites')); const partneredSatellitesJson = JSON.parse(MetaUtils.getMetaContent('partnered-satellites'));
const isBetaSatellite = MetaUtils.getMetaContent('is-beta-satellite'); 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) { if (satelliteName) {
this.$store.dispatch(APP_STATE_ACTIONS.SET_SATELLITE_NAME, 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'); this.$store.dispatch(APP_STATE_ACTIONS.SET_SATELLITE_STATUS, isBetaSatellite === 'true');
} }
if (couponCodeUIEnabled) { if (couponCodeBillingUIEnabled) {
this.$store.dispatch(APP_STATE_ACTIONS.SET_COUPON_CODE_UI_STATUS, couponCodeUIEnabled === 'true'); 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); 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" width="174px"
:on-press="onCreateClick" :on-press="onCreateClick"
label="Add Coupon Code" label="Add Coupon Code"
v-if="couponCodeUIEnabled" v-if="couponCodeBillingUIEnabled"
/> />
<div class="credit-history__container"> <div class="credit-history__container">
<div class="credit-history__content"> <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 { public get couponCodeBillingUIEnabled(): boolean {
return this.$store.state.appStateModule.couponCodeUIEnabled; return this.$store.state.appStateModule.couponCodeBillingUIEnabled;
} }
} }
</script> </script>

View File

@ -22,19 +22,11 @@
class="add-coupon__check" class="add-coupon__check"
v-if="isCodeValid" v-if="isCodeValid"
/> />
<VButton
label="Validate"
class="add-coupon__claim-button"
width="120px"
height="32px"
v-if="!isCodeValid"
:on-press="onValidationCheckClick"
/>
</div> </div>
<ValidationMessage <ValidationMessage
class="add-coupon__valid-message" class="add-coupon__valid-message"
successMessage="Get 50GB free and start using Storj DCS today." successMessage="Successfully applied coupon code."
errorMessage="Invalid code. Please Try again" :errorMessage="errorMessage"
:isValid="isCodeValid" :isValid="isCodeValid"
:showMessage="showValidationMessage" :showMessage="showValidationMessage"
/> />
@ -44,6 +36,7 @@
width="85%" width="85%"
height="44px" height="44px"
v-if="!isSignupView" v-if="!isSignupView"
:on-press="onApplyClick"
/> />
</div> </div>
</template> </template>
@ -59,6 +52,11 @@ import CloseIcon from '@/../static/images/common/closeCross.svg';
import CheckIcon from '@/../static/images/common/validCheck.svg'; import CheckIcon from '@/../static/images/common/validCheck.svg';
import { RouteConfig } from '@/router'; import { RouteConfig } from '@/router';
import { PAYMENTS_ACTIONS } from '@/store/modules/payments';
const {
APPLY_COUPON_CODE,
} = PAYMENTS_ACTIONS;
@Component({ @Component({
components: { components: {
@ -70,11 +68,12 @@ import { RouteConfig } from '@/router';
}, },
}) })
export default class AddCouponCodeInput extends Vue { export default class AddCouponCodeInput extends Vue {
@Prop({default: false})
private showValidationMessage = false;
private errorMessage = '';
private isCodeValid = false;
@Prop({default: false}) private couponCode = '';
protected readonly isCodeValid: boolean;
@Prop({default: false})
protected readonly showValidationMessage: boolean;
/** /**
* Signup view requires some unque styling and element text. * Signup view requires some unque styling and element text.
@ -91,13 +90,27 @@ export default class AddCouponCodeInput extends Vue {
return this.isSignupView ? 'Add Coupon' : ''; return this.isSignupView ? 'Add Coupon' : '';
} }
public setCouponCode(value: string): void {
this.couponCode = value;
}
/** /**
* Check if coupon code is valid * Check if coupon code is valid
*/ */
public onValidationCheckClick(): boolean { public async onApplyClick() {
return true; 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> </script>
@ -118,15 +131,6 @@ export default class AddCouponCodeInput extends Vue {
position: relative; position: relative;
} }
&__claim-button {
position: absolute;
right: 12px;
bottom: 8px;
font-size: 14px;
padding: 2px 0;
z-index: 23;
}
&__valid-message { &__valid-message {
position: relative; position: relative;
top: 15px; top: 15px;
@ -138,10 +142,10 @@ export default class AddCouponCodeInput extends Vue {
right: 0; right: 0;
margin: 0 auto; margin: 0 auto;
bottom: 40px; bottom: 40px;
background: #93a1af; background: #2683ff;
&:hover { &:hover {
background: darken(#93a1af, 10%); background: #0059d0;
} }
} }

View File

@ -40,9 +40,8 @@ export default class ValidationMessage extends Vue {
&__wrapper { &__wrapper {
box-sizing: border-box; box-sizing: border-box;
border-radius: 6px; border-radius: 6px;
height: 52px;
width: 100%; width: 100%;
padding-left: 25px; padding: 12px 25px;
} }
&__text { &__text {

View File

@ -32,7 +32,8 @@ export const appStateModule = {
satelliteName: '', satelliteName: '',
partneredSatellites: new Array<PartneredSatellite>(), partneredSatellites: new Array<PartneredSatellite>(),
isBetaSatellite: false, isBetaSatellite: false,
couponCodeUIEnabled: false, couponCodeBillingUIEnabled: false,
couponCodeSignupUIEnabled: false,
}, },
mutations: { mutations: {
// Mutation changing add projectMembers members popup visibility // 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 { [APP_STATE_MUTATIONS.SET_SATELLITE_STATUS](state: any, isBetaSatellite: boolean): void {
state.isBetaSatellite = isBetaSatellite; state.isBetaSatellite = isBetaSatellite;
}, },
[APP_STATE_MUTATIONS.SET_COUPON_CODE_UI_STATUS](state: any, couponCodeUIEnabled: boolean): void { [APP_STATE_MUTATIONS.SET_COUPON_CODE_BILLING_UI_STATUS](state: any, couponCodeBillingUIEnabled: boolean): void {
state.couponCodeUIEnabled = couponCodeUIEnabled; state.couponCodeBillingUIEnabled = couponCodeBillingUIEnabled;
},
[APP_STATE_MUTATIONS.SET_COUPON_CODE_SIGNUP_UI_STATUS](state: any, couponCodeSignupUIEnabled: boolean): void {
state.couponCodeSignupUIEnabled = couponCodeSignupUIEnabled;
}, },
}, },
actions: { actions: {
@ -261,8 +265,11 @@ export const appStateModule = {
[APP_STATE_ACTIONS.SET_SATELLITE_STATUS]: function ({commit}: any, isBetaSatellite: boolean): void { [APP_STATE_ACTIONS.SET_SATELLITE_STATUS]: function ({commit}: any, isBetaSatellite: boolean): void {
commit(APP_STATE_MUTATIONS.SET_SATELLITE_STATUS, isBetaSatellite); commit(APP_STATE_MUTATIONS.SET_SATELLITE_STATUS, isBetaSatellite);
}, },
[APP_STATE_ACTIONS.SET_COUPON_CODE_UI_STATUS]: function ({commit}: any, couponCodeUIEnabled: boolean): void { [APP_STATE_ACTIONS.SET_COUPON_CODE_BILLING_UI_STATUS]: function ({commit}: any, couponCodeBillingUIEnabled: boolean): void {
commit(APP_STATE_MUTATIONS.SET_COUPON_CODE_UI_STATUS, couponCodeUIEnabled); 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: 'getProjectUsageAndCharges',
GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP: 'getProjectUsageAndChargesCurrentRollup', GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP: 'getProjectUsageAndChargesCurrentRollup',
GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP: 'getProjectUsageAndChargesPreviousRollup', GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP: 'getProjectUsageAndChargesPreviousRollup',
APPLY_COUPON_CODE: 'applyCouponCode',
}; };
const { const {
@ -75,6 +76,7 @@ const {
MAKE_TOKEN_DEPOSIT, MAKE_TOKEN_DEPOSIT,
GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP, GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP,
GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP, GET_PROJECT_USAGE_AND_CHARGES_PREVIOUS_ROLLUP,
APPLY_COUPON_CODE,
} = PAYMENTS_ACTIONS; } = PAYMENTS_ACTIONS;
export class PaymentsState { export class PaymentsState {
@ -252,6 +254,9 @@ export function makePaymentsModule(api: PaymentsApi): StoreModule<PaymentsState>
commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges); commit(SET_PROJECT_USAGE_AND_CHARGES, usageAndCharges);
commit(SET_PRICE_SUMMARY, usageAndCharges); commit(SET_PRICE_SUMMARY, usageAndCharges);
}, },
[APPLY_COUPON_CODE]: async function({commit}: any, code: string): Promise<void> {
await api.applyCouponCode(code);
},
}, },
getters: { getters: {
canUserCreateFirstProject: (state: PaymentsState): boolean => { canUserCreateFirstProject: (state: PaymentsState): boolean => {

View File

@ -34,5 +34,6 @@ export const APP_STATE_MUTATIONS = {
SET_SATELLITE_NAME: 'SET_SATELLITE_NAME', SET_SATELLITE_NAME: 'SET_SATELLITE_NAME',
SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES', SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES',
SET_SATELLITE_STATUS: 'SET_SATELLITE_STATUS', 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 * @throws Error
*/ */
makeTokenDeposit(amount: number): Promise<TokenDeposit>; makeTokenDeposit(amount: number): Promise<TokenDeposit>;
/**
* applyCouponCode applies a coupon code.
*
* @param couponCode
* @throws Error
*/
applyCouponCode(couponCode: string): Promise<void>;
} }
export class AccountBalance { export class AccountBalance {

View File

@ -28,7 +28,8 @@ export const APP_STATE_ACTIONS = {
SET_SATELLITE_NAME: 'SET_SATELLITE_NAME', SET_SATELLITE_NAME: 'SET_SATELLITE_NAME',
SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES', SET_PARTNERED_SATELLITES: 'SET_PARTNERED_SATELLITES',
SET_SATELLITE_STATUS: 'SET_SATELLITE_STATUS', 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 = { export const NOTIFICATION_ACTIONS = {

View File

@ -151,7 +151,7 @@
is-password="true" is-password="true"
/> />
</div> </div>
<AddCouponCodeInput v-if="couponCodeUIEnabled" /> <AddCouponCodeInput v-if="couponCodeSignupUIEnabled" />
<div v-if="isBetaSatellite" class="register-area__input-area__container__warning"> <div v-if="isBetaSatellite" class="register-area__input-area__container__warning">
<div class="register-area__input-area__container__warning__header"> <div class="register-area__input-area__container__warning__header">
<label class="container"> <label class="container">
@ -463,8 +463,8 @@ export default class RegisterArea extends Vue {
/** /**
* Indicates if coupon code ui is enabled * Indicates if coupon code ui is enabled
*/ */
public get couponCodeUIEnabled(): boolean { public get couponCodeSignupUIEnabled(): boolean {
return this.$store.state.appStateModule.couponCodeUIEnabled; 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')); return Promise.resolve(new TokenDeposit(amount, 'testAddress', 'testLink'));
} }
getPaywallStatus(userId: string): Promise<boolean> { applyCouponCode(code: string): Promise<void> {
throw new Error('Method not implemented'); throw new Error('Method not implemented');
} }
} }