satellite/console: purchase package grants credit

Instead of granting a coupon when purchasing a package, grant credit.
This changes paymentsconfig.PackagePlan to use credit amount rather than
coupon ID. Add additional check to see if a paid invoice with the
description exists. If so, don't create and pay another invoice.

Change-Id: I81df24984c519c773db5fc8e9070bd7797070ec2
This commit is contained in:
Cameron 2023-03-22 11:28:52 -04:00 committed by Storj Robot
parent a2e3247471
commit c2cd213c4f
11 changed files with 79 additions and 129 deletions

View File

@ -499,12 +499,6 @@ func (p *Payments) PurchasePackage(w http.ResponseWriter, r *http.Request) {
return return
} }
_, err = p.service.Payments().ApplyCoupon(ctx, pkg.CouponID)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
card, err := p.service.Payments().AddCreditCard(ctx, token) card, err := p.service.Payments().AddCreditCard(ctx, token)
if err != nil { if err != nil {
switch { switch {
@ -516,8 +510,22 @@ func (p *Payments) PurchasePackage(w http.ResponseWriter, r *http.Request) {
return return
} }
err = p.service.Payments().Purchase(ctx, pkg.Price, fmt.Sprintf("%s package plan", string(u.UserAgent)), card.ID) description := fmt.Sprintf("%s package plan", string(u.UserAgent))
err = p.service.Payments().UpdatePackage(ctx, description, time.Now())
if err != nil { if err != nil {
if !console.ErrAlreadyHasPackage.Has(err) {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
err = p.service.Payments().Purchase(ctx, pkg.Price, description, card.ID)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
return
}
if err = p.service.Payments().ApplyCredit(ctx, pkg.Credit, description); err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err) p.serveJSONError(w, http.StatusInternalServerError, err)
return return
} }

View File

@ -25,7 +25,6 @@ import (
func Test_PurchasePackage(t *testing.T) { func Test_PurchasePackage(t *testing.T) {
partner := "partner1" partner := "partner1"
partner2 := "partner2"
testplanet.Run(t, testplanet.Config{ testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
@ -35,8 +34,7 @@ func Test_PurchasePackage(t *testing.T) {
config.Console.RateLimit.Burst = 10 config.Console.RateLimit.Burst = 10
config.Payments.StripeCoinPayments.StripeFreeTierCouponID = stripecoinpayments.MockCouponID1 config.Payments.StripeCoinPayments.StripeFreeTierCouponID = stripecoinpayments.MockCouponID1
config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{ config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{
partner: {CouponID: stripecoinpayments.MockCouponID2, Price: 1000}, partner: {Credit: 2000, Price: 1000},
partner2: {CouponID: "invalidCouponID", Price: 1000},
} }
}, },
}, },
@ -53,12 +51,6 @@ func Test_PurchasePackage(t *testing.T) {
"No matching package plan for partner", validCardToken, "unknownPartner", "No matching package plan for partner", validCardToken, "unknownPartner",
http.StatusNotFound, http.StatusNotFound,
}, },
{
// partner2's coupon ID configured above in Reconfigure does not exist in underlying
// stipe mock coupons list.
"Coupon doesn't exist", validCardToken, partner2,
http.StatusInternalServerError,
},
{ {
"Add credit card fails", stripecoinpayments.TestPaymentMethodsNewFailure, partner, "Add credit card fails", stripecoinpayments.TestPaymentMethodsNewFailure, partner,
http.StatusInternalServerError, http.StatusInternalServerError,
@ -71,6 +63,10 @@ func Test_PurchasePackage(t *testing.T) {
"Success", validCardToken, partner, "Success", validCardToken, partner,
http.StatusOK, http.StatusOK,
}, },
{
"Subsequent request succeeds", validCardToken, partner,
http.StatusOK,
},
} }
for i, tt := range tests { for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -88,7 +84,7 @@ func Test_PurchasePackage(t *testing.T) {
tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName})
require.NoError(t, err) require.NoError(t, err)
req, err := http.NewRequestWithContext(userCtx, "POST", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/payments/purchase-package", strings.NewReader(tt.cardToken)) req, err := http.NewRequestWithContext(userCtx, "POST", "http://"+sat.API.Console.Listener.Addr().String()+"/api/v0/payments/purchase-package", strings.NewReader(tt.cardToken))
require.NoError(t, err) require.NoError(t, err)
expire := time.Now().AddDate(0, 0, 1) expire := time.Now().AddDate(0, 0, 1)
@ -119,7 +115,7 @@ func Test_PackageAvailable(t *testing.T) {
Reconfigure: testplanet.Reconfigure{ Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) { Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{ config.Payments.PackagePlans.Packages = map[string]payments.PackagePlan{
pkgPartner: {CouponID: stripecoinpayments.MockCouponID1, Price: 1000}, pkgPartner: {Credit: 2000, Price: 1000},
} }
}, },
}, },

View File

@ -3109,6 +3109,8 @@ func (payment Payments) WalletPayments(ctx context.Context) (_ WalletPayments, e
} }
// Purchase makes a purchase of `price` amount with description of `desc` and payment method with id of `paymentMethodID`. // Purchase makes a purchase of `price` amount with description of `desc` and payment method with id of `paymentMethodID`.
// If a paid invoice with the same description exists, then we assume this is a retried request and don't create and pay
// another invoice.
func (payment Payments) Purchase(ctx context.Context, price int64, desc string, paymentMethodID string) (err error) { func (payment Payments) Purchase(ctx context.Context, price int64, desc string, paymentMethodID string) (err error) {
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
@ -3127,8 +3129,12 @@ func (payment Payments) Purchase(ctx context.Context, price int64, desc string,
// check for any previously created unpaid invoice with the same description. // check for any previously created unpaid invoice with the same description.
// If draft, delete it and create new and pay. If open, pay it and don't create new. // If draft, delete it and create new and pay. If open, pay it and don't create new.
// If paid, skip.
for _, inv := range invoices { for _, inv := range invoices {
if inv.Description == desc { if inv.Description == desc {
if inv.Status == payments.InvoiceStatusPaid {
return nil
}
if inv.Status == payments.InvoiceStatusDraft { if inv.Status == payments.InvoiceStatusDraft {
_, err := payment.service.accounts.Invoices().Delete(ctx, inv.ID) _, err := payment.service.accounts.Invoices().Delete(ctx, inv.ID)
if err != nil { if err != nil {

View File

@ -1656,7 +1656,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
userCtx, err := sat.UserContext(ctx, user.ID) userCtx, err := sat.UserContext(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
testDesc := "testDescription" draftInvDesc := "testDraftDescription"
testPaymentMethod := "testPaymentMethod" testPaymentMethod := "testPaymentMethod"
invs, err := sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID) invs, err := sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
@ -1664,7 +1664,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
require.Len(t, invs, 0) require.Len(t, invs, 0)
// test purchase with draft invoice // test purchase with draft invoice
inv, err := sat.API.Payments.StripeService.Accounts().Invoices().Create(ctx, user.ID, 1000, testDesc) inv, err := sat.API.Payments.StripeService.Accounts().Invoices().Create(ctx, user.ID, 1000, draftInvDesc)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, payments.InvoiceStatusDraft, inv.Status) require.Equal(t, payments.InvoiceStatusDraft, inv.Status)
@ -1675,7 +1675,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
require.Len(t, invs, 1) require.Len(t, invs, 1)
require.Equal(t, draftInv, invs[0].ID) require.Equal(t, draftInv, invs[0].ID)
require.NoError(t, p.Purchase(userCtx, 1000, testDesc, testPaymentMethod)) require.NoError(t, p.Purchase(userCtx, 1000, draftInvDesc, testPaymentMethod))
invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID) invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
@ -1684,7 +1684,8 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
require.Equal(t, payments.InvoiceStatusPaid, invs[0].Status) require.Equal(t, payments.InvoiceStatusPaid, invs[0].Status)
// test purchase with open invoice // test purchase with open invoice
inv, err = sat.API.Payments.StripeService.Accounts().Invoices().Create(ctx, user.ID, 1000, testDesc) openInvDesc := "testOpenDescription"
inv, err = sat.API.Payments.StripeService.Accounts().Invoices().Create(ctx, user.ID, 1000, openInvDesc)
require.NoError(t, err) require.NoError(t, err)
openInv := inv.ID openInv := inv.ID
@ -1705,7 +1706,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
} }
require.True(t, foundInv) require.True(t, foundInv)
require.NoError(t, p.Purchase(userCtx, 1000, testDesc, testPaymentMethod)) require.NoError(t, p.Purchase(userCtx, 1000, openInvDesc, testPaymentMethod))
invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID) invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
@ -1718,6 +1719,13 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
} }
} }
require.True(t, foundInv) require.True(t, foundInv)
// purchase with paid invoice skips creating and or paying invoice
require.NoError(t, p.Purchase(userCtx, 1000, openInvDesc, testPaymentMethod))
invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
require.NoError(t, err)
require.Len(t, invs, 2)
}) })
} }

View File

@ -40,3 +40,10 @@ type BalanceTransaction struct {
Amount int64 Amount int64
Description string Description string
} }
// PackagePlan is an amount to charge a user one time in exchange for credit of greater value.
// Price and Credit are in cents USD.
type PackagePlan struct {
Price int64
Credit int64
}

View File

@ -68,10 +68,3 @@ const (
// SignupCoupon represents a valid promo code coupon. // SignupCoupon represents a valid promo code coupon.
SignupCoupon = "signupCoupon" 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

@ -37,7 +37,7 @@ type Config struct {
NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"` NodeAuditBandwidthPrice int64 `help:"price node receive for storing TB of audit in cents" default:"1000"`
NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"` NodeDiskSpacePrice int64 `help:"price node receive for storing disk space in cents/TB" default:"150"`
UsagePriceOverrides ProjectUsagePriceOverrides `help:"semicolon-separated usage price overrides in the format partner:storage,egress,segment"` UsagePriceOverrides ProjectUsagePriceOverrides `help:"semicolon-separated usage price overrides in the format partner:storage,egress,segment"`
PackagePlans PackagePlans `help:"semicolon-separated partner package plans in the format partner:couponID,price. Price is in cents USD."` PackagePlans PackagePlans `help:"semicolon-separated partner package plans in the format partner:price,credit. Price and credit are in cents USD."`
} }
// ProjectUsagePrice holds the configuration for the satellite's project usage price model. // ProjectUsagePrice holds the configuration for the satellite's project usage price model.
@ -172,7 +172,7 @@ func (p *PackagePlans) String() string {
var s strings.Builder var s strings.Builder
left := len(p.Packages) left := len(p.Packages)
for partner, pkg := range p.Packages { for partner, pkg := range p.Packages {
s.WriteString(fmt.Sprintf("%s:%s,%d", partner, pkg.CouponID, pkg.Price)) s.WriteString(fmt.Sprintf("%s:%d,%d", partner, pkg.Price, pkg.Credit))
left-- left--
if left > 0 { if left > 0 {
s.WriteRune(';') s.WriteRune(';')
@ -191,7 +191,7 @@ func (p *PackagePlans) Set(s string) error {
info := strings.Split(packagePlansStr, ":") info := strings.Split(packagePlansStr, ":")
if len(info) != 2 { if len(info) != 2 {
return Error.New("Invalid package plan (expected format partner:couponID,price got %s)", packagePlansStr) return Error.New("Invalid package plan (expected format partner:price,credit got %s)", packagePlansStr)
} }
partner := strings.TrimSpace(info[0]) partner := strings.TrimSpace(info[0])
@ -202,21 +202,26 @@ func (p *PackagePlans) Set(s string) error {
packageStr := info[1] packageStr := info[1]
pkg := strings.Split(packageStr, ",") pkg := strings.Split(packageStr, ",")
if len(pkg) != 2 || pkg[0] == "" { if len(pkg) != 2 || pkg[0] == "" {
return Error.New("Invalid package (expected format couponID,price got %s)", packageStr) return Error.New("Invalid package (expected format price,credit got %s)", packageStr)
} }
if _, err := decimal.NewFromString(pkg[1]); err != nil { if _, err := decimal.NewFromString(pkg[1]); err != nil {
return Error.New("Invalid price (%s)", err) return Error.New("Invalid price (%s)", err)
} }
cents, err := strconv.Atoi(pkg[1]) priceCents, err := strconv.Atoi(pkg[0])
if err != nil {
return Error.Wrap(err)
}
creditCents, err := strconv.Atoi(pkg[1])
if err != nil { if err != nil {
return Error.Wrap(err) return Error.Wrap(err)
} }
packages[info[0]] = payments.PackagePlan{ packages[info[0]] = payments.PackagePlan{
CouponID: pkg[0], Price: int64(priceCents),
Price: int64(cents), Credit: int64(creditCents),
} }
} }
p.Packages = packages p.Packages = packages

View File

@ -120,43 +120,43 @@ func TestPackagePlans(t *testing.T) {
}, },
{ {
testID: "missing partner", testID: "missing partner",
configValue: ":abc123,100", configValue: ":100,100",
}, {
testID: "empty coupon ID",
configValue: "partner:,1",
}, { }, {
testID: "empty price", testID: "empty price",
configValue: "partner:abc123,", configValue: "partner:,100",
}, {
testID: "empty credit",
configValue: "partner:100,",
}, },
{ {
testID: "too few values", testID: "too few values",
configValue: "partner:abc123", configValue: "partner:100",
}, },
{ {
testID: "too many values", testID: "too many values",
configValue: "partner:abc123,100,200", configValue: "partner:100,100,200",
}, },
{ {
testID: "single package plan", testID: "single package plan",
configValue: "partner1:abc123,100", configValue: "partner1:100,200",
expectedPackagePlans: packages{ expectedPackagePlans: packages{
"partner1": payments.PackagePlan{ "partner1": payments.PackagePlan{
CouponID: "abc123", Price: 100,
Price: 100, Credit: 200,
}, },
}, },
}, },
{ {
testID: "multiple package plans", testID: "multiple package plans",
configValue: "partner1:abc123,100;partner2:321bca,200", configValue: "partner1:100,200;partner2:200,300",
expectedPackagePlans: packages{ expectedPackagePlans: packages{
"partner1": payments.PackagePlan{ "partner1": payments.PackagePlan{
CouponID: "abc123", Price: 100,
Price: 100, Credit: 200,
}, },
"partner2": payments.PackagePlan{ "partner2": payments.PackagePlan{
CouponID: "321bca", Price: 200,
Price: 200, Credit: 300,
}, },
}, },
}, },
@ -188,9 +188,9 @@ func TestPackagePlans(t *testing.T) {
func TestPackagePlansGet(t *testing.T) { func TestPackagePlansGet(t *testing.T) {
partner := "partnerName1" partner := "partnerName1"
coupon := "abc123" credit := int64(200)
price := int64(100) price := int64(100)
configStr := fmt.Sprintf("%s:%s,%d", partner, coupon, price) configStr := fmt.Sprintf("%s:%d,%d", partner, price, credit)
packagePlans := paymentsconfig.PackagePlans{} packagePlans := paymentsconfig.PackagePlans{}
require.NoError(t, packagePlans.Set(configStr)) require.NoError(t, packagePlans.Set(configStr))
@ -231,7 +231,7 @@ func TestPackagePlansGet(t *testing.T) {
p, err := packagePlans.Get(c.userAgent) p, err := packagePlans.Get(c.userAgent)
if c.shouldPass { if c.shouldPass {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, coupon, p.CouponID) require.Equal(t, credit, p.Credit)
require.Equal(t, price, p.Price) require.Equal(t, price, p.Price)
} else { } else {
require.Error(t, err) require.Error(t, err)

View File

@ -66,24 +66,6 @@ func (coupons *coupons) ApplyCoupon(ctx context.Context, userID uuid.UUID, coupo
func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (_ *payments.Coupon, err error) { func (coupons *coupons) ApplyCouponCode(ctx context.Context, userID uuid.UUID, couponCode string) (_ *payments.Coupon, err error) {
defer mon.Task()(&ctx, userID, couponCode)(&err) 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, payments.ErrCouponConflict.New("coupon for partner '%s' should not be replaced", partner)
}
}
}
promoCodeIter := coupons.service.stripeClient.PromoCodes().List(&stripe.PromotionCodeListParams{ promoCodeIter := coupons.service.stripeClient.PromoCodes().List(&stripe.PromotionCodeListParams{
ListParams: stripe.ListParams{Context: ctx}, ListParams: stripe.ListParams{Context: ctx},
Code: stripe.String(couponCode), Code: stripe.String(couponCode),

View File

@ -13,64 +13,9 @@ import (
"storj.io/common/testrand" "storj.io/common/testrand"
"storj.io/storj/private/testplanet" "storj.io/storj/private/testplanet"
"storj.io/storj/satellite" "storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripecoinpayments" "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, payments.ErrCouponConflict.Has(err))
})
})
}
func TestCoupons(t *testing.T) { func TestCoupons(t *testing.T) {
testplanet.Run(t, testplanet.Config{ testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,

View File

@ -826,7 +826,7 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
# price node receive for storing TB of repair in cents # price node receive for storing TB of repair in cents
# payments.node-repair-bandwidth-price: 1000 # payments.node-repair-bandwidth-price: 1000
# semicolon-separated partner package plans in the format partner:couponID,price. Price is in cents USD. # semicolon-separated partner package plans in the format partner:price,credit. Price and credit are in cents USD.
# payments.package-plans: "" # payments.package-plans: ""
# payments provider to use # payments provider to use