satellite/payments/stripecoinpayments: forbid replacing partner coupons
Users with a partner package plan should be unable to replace their plan's coupon. This change enforces this behavior by rejecting coupon application attempts from users that meet this criteria. Change-Id: I6383d19f2c7fbd9e1a2826473b2f867ea8a8ea3e
This commit is contained in:
parent
f3f22d8443
commit
add3034b43
@ -71,9 +71,11 @@ func setupPayments(log *zap.Logger, db satellite.DB) (*stripecoinpayments.Servic
|
||||
db.Wallets(),
|
||||
db.Billing(),
|
||||
db.Console().Projects(),
|
||||
db.Console().Users(),
|
||||
db.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
}
|
||||
|
||||
|
@ -168,9 +168,11 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
|
||||
peer.DB.Wallets(),
|
||||
peer.DB.Billing(),
|
||||
peer.DB.Console().Projects(),
|
||||
peer.DB.Console().Users(),
|
||||
peer.DB.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
|
||||
if err != nil {
|
||||
|
@ -548,9 +548,11 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
|
||||
peer.DB.Wallets(),
|
||||
peer.DB.Billing(),
|
||||
peer.DB.Console().Projects(),
|
||||
peer.DB.Console().Users(),
|
||||
peer.DB.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
|
||||
if err != nil {
|
||||
|
@ -324,11 +324,13 @@ func (p *Payments) ApplyCouponCode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
coupon, err := p.service.Payments().ApplyCouponCode(ctx, couponCode)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if stripecoinpayments.ErrInvalidCoupon.Has(err) {
|
||||
p.serveJSONError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
status = http.StatusBadRequest
|
||||
} else if stripecoinpayments.ErrCouponConflict.Has(err) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
p.serveJSONError(w, http.StatusInternalServerError, err)
|
||||
p.serveJSONError(w, status, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -92,9 +92,11 @@ func TestGraphqlMutation(t *testing.T) {
|
||||
db.Wallets(),
|
||||
db.Billing(),
|
||||
db.Console().Projects(),
|
||||
db.Console().Users(),
|
||||
db.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -76,9 +76,11 @@ func TestGraphqlQuery(t *testing.T) {
|
||||
db.Wallets(),
|
||||
db.Billing(),
|
||||
db.Console().Projects(),
|
||||
db.Console().Users(),
|
||||
db.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -542,9 +542,11 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
|
||||
peer.DB.Wallets(),
|
||||
peer.DB.Billing(),
|
||||
peer.DB.Console().Projects(),
|
||||
peer.DB.Console().Users(),
|
||||
peer.DB.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
if err != nil {
|
||||
return nil, errs.Combine(err, peer.Close())
|
||||
|
@ -56,3 +56,10 @@ const (
|
||||
// SignupCoupon represents a valid promo code coupon.
|
||||
SignupCoupon = "signupCoupon"
|
||||
)
|
||||
|
||||
// PackagePlan is an amount to charge a user one time in exchange for a coupon of greater value.
|
||||
// Price is in cents USD.
|
||||
type PackagePlan struct {
|
||||
CouponID string
|
||||
Price int64
|
||||
}
|
||||
|
@ -154,16 +154,9 @@ func (p ProjectUsagePriceOverrides) ToModels() (map[string]payments.ProjectUsage
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// PackagePlan is an amount to charge a user one time in exchange for a coupon of greater value.
|
||||
// Price is in cents USD.
|
||||
type PackagePlan struct {
|
||||
CouponID string
|
||||
Price int64
|
||||
}
|
||||
|
||||
// PackagePlans contains one time prices for partners.
|
||||
type PackagePlans struct {
|
||||
packages map[string]PackagePlan
|
||||
Packages map[string]payments.PackagePlan
|
||||
}
|
||||
|
||||
// Type returns the type of the pflag.Value.
|
||||
@ -175,8 +168,8 @@ func (p *PackagePlans) String() string {
|
||||
return ""
|
||||
}
|
||||
var s strings.Builder
|
||||
left := len(p.packages)
|
||||
for partner, pkg := range p.packages {
|
||||
left := len(p.Packages)
|
||||
for partner, pkg := range p.Packages {
|
||||
s.WriteString(fmt.Sprintf("%s:%s,%d", partner, pkg.CouponID, pkg.Price))
|
||||
left--
|
||||
if left > 0 {
|
||||
@ -188,7 +181,7 @@ func (p *PackagePlans) String() string {
|
||||
|
||||
// Set sets the list of pricing plans to the parsed string.
|
||||
func (p *PackagePlans) Set(s string) error {
|
||||
packages := make(map[string]PackagePlan)
|
||||
packages := make(map[string]payments.PackagePlan)
|
||||
for _, packagePlansStr := range strings.Split(s, ";") {
|
||||
if packagePlansStr == "" {
|
||||
continue
|
||||
@ -219,25 +212,25 @@ func (p *PackagePlans) Set(s string) error {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
packages[info[0]] = PackagePlan{
|
||||
packages[info[0]] = payments.PackagePlan{
|
||||
CouponID: pkg[0],
|
||||
Price: int64(cents),
|
||||
}
|
||||
}
|
||||
p.packages = packages
|
||||
p.Packages = packages
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get a package plan by user agent.
|
||||
func (p *PackagePlans) Get(userAgent []byte) (pkg PackagePlan, err error) {
|
||||
func (p *PackagePlans) Get(userAgent []byte) (pkg payments.PackagePlan, err error) {
|
||||
entries, err := useragent.ParseEntries(userAgent)
|
||||
if err != nil {
|
||||
return PackagePlan{}, Error.Wrap(err)
|
||||
return payments.PackagePlan{}, Error.Wrap(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if pkg, ok := p.packages[entry.Product]; ok {
|
||||
if pkg, ok := p.Packages[entry.Product]; ok {
|
||||
return pkg, nil
|
||||
}
|
||||
}
|
||||
return PackagePlan{}, errs.New("no matching partner for (%s)", userAgent)
|
||||
return payments.PackagePlan{}, errs.New("no matching partner for (%s)", userAgent)
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ func TestProjectUsagePriceOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPackagePlans(t *testing.T) {
|
||||
type packages map[string]paymentsconfig.PackagePlan
|
||||
type packages map[string]payments.PackagePlan
|
||||
|
||||
cases := []struct {
|
||||
testID string
|
||||
@ -140,7 +140,7 @@ func TestPackagePlans(t *testing.T) {
|
||||
testID: "single package plan",
|
||||
configValue: "partner1:abc123,100",
|
||||
expectedPackagePlans: packages{
|
||||
"partner1": paymentsconfig.PackagePlan{
|
||||
"partner1": payments.PackagePlan{
|
||||
CouponID: "abc123",
|
||||
Price: 100,
|
||||
},
|
||||
@ -150,11 +150,11 @@ func TestPackagePlans(t *testing.T) {
|
||||
testID: "multiple package plans",
|
||||
configValue: "partner1:abc123,100;partner2:321bca,200",
|
||||
expectedPackagePlans: packages{
|
||||
"partner1": paymentsconfig.PackagePlan{
|
||||
"partner1": payments.PackagePlan{
|
||||
CouponID: "abc123",
|
||||
Price: 100,
|
||||
},
|
||||
"partner2": paymentsconfig.PackagePlan{
|
||||
"partner2": payments.PackagePlan{
|
||||
CouponID: "321bca",
|
||||
Price: 200,
|
||||
},
|
||||
|
@ -70,9 +70,11 @@ func TestSignupCouponCodes(t *testing.T) {
|
||||
db.Wallets(),
|
||||
db.Billing(),
|
||||
db.Console().Projects(),
|
||||
db.Console().Users(),
|
||||
db.ProjectAccounting(),
|
||||
prices,
|
||||
priceOverrides,
|
||||
pc.PackagePlans.Packages,
|
||||
pc.BonusRate)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -27,6 +27,24 @@ type coupons struct {
|
||||
func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (_ *payments.Coupon, err error) {
|
||||
defer mon.Task()(&ctx, userID, couponCode)(&err)
|
||||
|
||||
user, err := coupons.service.usersDB.Get(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, Error.Wrap(err)
|
||||
}
|
||||
|
||||
if user.UserAgent != nil {
|
||||
partner := string(user.UserAgent)
|
||||
if plan, ok := coupons.service.packagePlans[partner]; ok {
|
||||
coupon, err := coupons.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if coupon != nil && coupon.ID == plan.CouponID {
|
||||
return nil, ErrCouponConflict.New("coupon for partner '%s' should not be replaced", partner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
promoCodeIter := coupons.service.stripeClient.PromoCodes().List(&stripe.PromotionCodeListParams{
|
||||
Code: stripe.String(couponCode),
|
||||
})
|
||||
@ -92,7 +110,7 @@ func stripeDiscountToPaymentsCoupon(dc *stripe.Discount) (coupon *payments.Coupo
|
||||
}
|
||||
|
||||
coupon = &payments.Coupon{
|
||||
ID: dc.ID,
|
||||
ID: dc.Coupon.ID,
|
||||
Name: dc.Coupon.Name,
|
||||
AmountOff: dc.Coupon.AmountOff,
|
||||
PercentOff: dc.Coupon.PercentOff,
|
||||
|
71
satellite/payments/stripecoinpayments/coupons_test.go
Normal file
71
satellite/payments/stripecoinpayments/coupons_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package stripecoinpayments_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/common/testcontext"
|
||||
"storj.io/storj/private/testplanet"
|
||||
"storj.io/storj/satellite"
|
||||
"storj.io/storj/satellite/console"
|
||||
"storj.io/storj/satellite/payments"
|
||||
"storj.io/storj/satellite/payments/stripecoinpayments"
|
||||
)
|
||||
|
||||
func TestCouponConflict(t *testing.T) {
|
||||
const (
|
||||
partnerName = "partner"
|
||||
partnerCode = "promo1"
|
||||
standardCode = "promo2"
|
||||
)
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
SatelliteCount: 1,
|
||||
Reconfigure: testplanet.Reconfigure{
|
||||
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
||||
config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{
|
||||
partnerName: {CouponID: "c1"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
sat := planet.Satellites[0]
|
||||
coupons := sat.Core.Payments.Accounts.Coupons()
|
||||
|
||||
t.Run("standard user can replace partner coupon", func(t *testing.T) {
|
||||
user, err := sat.AddUser(ctx, console.CreateUser{
|
||||
FullName: "Test User",
|
||||
Email: "user@mail.test",
|
||||
}, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = coupons.ApplyCouponCode(ctx, user.ID, partnerCode)
|
||||
require.NoError(t, err)
|
||||
_, err = coupons.ApplyCouponCode(ctx, user.ID, standardCode)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
partneredUser, err := sat.AddUser(ctx, console.CreateUser{
|
||||
FullName: "Test User",
|
||||
Email: "user2@mail.test",
|
||||
UserAgent: []byte(partnerName),
|
||||
}, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("partnered user can replace standard coupon", func(t *testing.T) {
|
||||
_, err = coupons.ApplyCouponCode(ctx, partneredUser.ID, standardCode)
|
||||
require.NoError(t, err)
|
||||
_, err = coupons.ApplyCouponCode(ctx, partneredUser.ID, partnerCode)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("partnered user cannot replace partner coupon", func(t *testing.T) {
|
||||
_, err = coupons.ApplyCouponCode(ctx, partneredUser.ID, standardCode)
|
||||
require.True(t, stripecoinpayments.ErrCouponConflict.Has(err))
|
||||
})
|
||||
})
|
||||
}
|
@ -35,6 +35,9 @@ var (
|
||||
// ErrInvalidCoupon defines invalid coupon code error.
|
||||
ErrInvalidCoupon = errs.Class("invalid coupon code")
|
||||
|
||||
// ErrCouponConflict occurs when attempting to replace a protected coupon.
|
||||
ErrCouponConflict = errs.Class("coupon conflict")
|
||||
|
||||
mon = monkit.Package()
|
||||
)
|
||||
|
||||
@ -62,11 +65,13 @@ type Service struct {
|
||||
billingDB billing.TransactionsDB
|
||||
|
||||
projectsDB console.Projects
|
||||
usersDB console.Users
|
||||
usageDB accounting.ProjectAccounting
|
||||
stripeClient StripeClient
|
||||
|
||||
usagePrices payments.ProjectUsagePriceModel
|
||||
usagePriceOverrides map[string]payments.ProjectUsagePriceModel
|
||||
packagePlans map[string]payments.PackagePlan
|
||||
partnerNames []string
|
||||
// BonusRate amount of percents
|
||||
BonusRate int64
|
||||
@ -82,7 +87,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService creates a Service instance.
|
||||
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, walletsDB storjscan.WalletsDB, billingDB billing.TransactionsDB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, usagePrices payments.ProjectUsagePriceModel, usagePriceOverrides map[string]payments.ProjectUsagePriceModel, bonusRate int64) (*Service, error) {
|
||||
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, walletsDB storjscan.WalletsDB, billingDB billing.TransactionsDB, projectsDB console.Projects, usersDB console.Users, usageDB accounting.ProjectAccounting, usagePrices payments.ProjectUsagePriceModel, usagePriceOverrides map[string]payments.ProjectUsagePriceModel, packagePlans map[string]payments.PackagePlan, bonusRate int64) (*Service, error) {
|
||||
var partners []string
|
||||
for partner := range usagePriceOverrides {
|
||||
partners = append(partners, partner)
|
||||
@ -94,10 +99,12 @@ func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB
|
||||
walletsDB: walletsDB,
|
||||
billingDB: billingDB,
|
||||
projectsDB: projectsDB,
|
||||
usersDB: usersDB,
|
||||
usageDB: usageDB,
|
||||
stripeClient: stripeClient,
|
||||
usagePrices: usagePrices,
|
||||
usagePriceOverrides: usagePriceOverrides,
|
||||
packagePlans: packagePlans,
|
||||
partnerNames: partners,
|
||||
BonusRate: bonusRate,
|
||||
StripeFreeTierCouponID: config.StripeFreeTierCouponID,
|
||||
|
11
web/satellite/src/api/errors/ErrorConflict.ts
Normal file
11
web/satellite/src/api/errors/ErrorConflict.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
/**
|
||||
* ErrorConflict is a custom error type indicating operation failure due to a resource conflict.
|
||||
*/
|
||||
export class ErrorConflict extends Error {
|
||||
public constructor(message = 'Resource conflict') {
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2019 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
import { ErrorConflict } from './errors/ErrorConflict';
|
||||
import { ErrorTooManyRequests } from './errors/ErrorTooManyRequests';
|
||||
|
||||
import {
|
||||
@ -273,6 +274,8 @@ export class PaymentsHttpApi implements PaymentsApi {
|
||||
|
||||
if (!response.ok) {
|
||||
switch (response.status) {
|
||||
case 409:
|
||||
throw new ErrorConflict('You currently have an active coupon. Please try again when your coupon is no longer active, or contact Support for further help.');
|
||||
case 429:
|
||||
throw new ErrorTooManyRequests('You\'ve exceeded limit of attempts, try again in 5 minutes');
|
||||
default:
|
||||
|
Loading…
Reference in New Issue
Block a user