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:
Jeremy Wharton 2023-01-30 16:11:12 -06:00 committed by Storj Robot
parent f3f22d8443
commit add3034b43
16 changed files with 152 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View 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);
}
}

View File

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