satellite/payments: Apply Stripe free tier coupon for new customers
Rather than applying our internal satellite implementation of coupons when new accounts are created, use a configured Stripe coupon instead. If no configuration is set, no coupon will be applied. This change also removes logic for adding coupons to customers who pay with crypto - they will already have the free tier coupon applied anyway. We will be phasing out our internal coupon implementation. Change-Id: Ieb87ddb3412acbc74986aa9d18a4cbd93c29861a
This commit is contained in:
parent
92226d8ddb
commit
02fc87e98b
@ -234,12 +234,6 @@ func (paymentService PaymentsService) AddCreditCard(ctx context.Context, creditC
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: check if this is the right place
|
||||
err = paymentService.AddPromotionalCoupon(ctx, auth.User.ID)
|
||||
if err != nil {
|
||||
paymentService.service.log.Warn(fmt.Sprintf("could not add promotional coupon for user %s", auth.User.ID.String()), zap.Error(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -680,12 +674,6 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: check if this is the right place
|
||||
err = s.accounts.Coupons().AddPromotionalCoupon(ctx, user.ID)
|
||||
if err != nil {
|
||||
s.log.Debug(fmt.Sprintf("could not add promotional coupon for user %s", user.ID.String()), zap.Error(Error.Wrap(err)))
|
||||
}
|
||||
|
||||
s.analytics.TrackAccountVerified(user.ID, user.Email)
|
||||
|
||||
return nil
|
||||
|
@ -47,6 +47,10 @@ func (accounts *accounts) Setup(ctx context.Context, userID uuid.UUID, email str
|
||||
params := &stripe.CustomerParams{
|
||||
Email: stripe.String(email),
|
||||
}
|
||||
// If a free tier coupon is provided, apply this on account creation.
|
||||
if accounts.service.StripeFreeTierCouponID != "" {
|
||||
params.Coupon = stripe.String(accounts.service.StripeFreeTierCouponID)
|
||||
}
|
||||
|
||||
customer, err := accounts.service.stripeClient.Customers().New(params)
|
||||
if err != nil {
|
||||
|
@ -20,7 +20,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/common/memory"
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/satellite/accounting"
|
||||
"storj.io/storj/satellite/console"
|
||||
"storj.io/storj/satellite/payments"
|
||||
@ -43,6 +42,7 @@ const hoursPerMonth = 24 * 30
|
||||
type Config struct {
|
||||
StripeSecretKey string `help:"stripe API secret key" default:""`
|
||||
StripePublicKey string `help:"stripe API public key" default:""`
|
||||
StripeFreeTierCouponID string `help:"stripe free tier coupon ID" default:""`
|
||||
CoinpaymentsPublicKey string `help:"coinpayments API public key" default:""`
|
||||
CoinpaymentsPrivateKey string `help:"coinpayments API private key key" default:""`
|
||||
TransactionUpdateInterval time.Duration `help:"amount of time we wait before running next transaction update loop" default:"2m"`
|
||||
@ -69,9 +69,10 @@ type Service struct {
|
||||
// BonusRate amount of percents
|
||||
BonusRate int64
|
||||
// Coupon Values
|
||||
CouponValue int64
|
||||
CouponDuration *int64
|
||||
CouponProjectLimit memory.Size
|
||||
StripeFreeTierCouponID string
|
||||
CouponValue int64
|
||||
CouponDuration *int64
|
||||
CouponProjectLimit memory.Size
|
||||
// Minimum CoinPayment to create a coupon
|
||||
MinCoinPayment int64
|
||||
|
||||
@ -126,6 +127,7 @@ func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB
|
||||
EgressMBPriceCents: egressMBPriceCents,
|
||||
ObjectMonthPriceCents: objectMonthPriceCents,
|
||||
BonusRate: bonusRate,
|
||||
StripeFreeTierCouponID: config.StripeFreeTierCouponID,
|
||||
CouponValue: couponValue,
|
||||
CouponDuration: couponDuration,
|
||||
CouponProjectLimit: couponProjectLimit,
|
||||
@ -209,28 +211,6 @@ func (service *Service) updateTransactions(ctx context.Context, ids TransactionA
|
||||
mon.IntVal("coinpayment_duration").Observe(int64(time.Since(creationTimes[id])))
|
||||
applies = append(applies, id)
|
||||
}
|
||||
|
||||
userID := ids[id]
|
||||
|
||||
if !service.Accounts().PaywallEnabled(userID) {
|
||||
continue
|
||||
}
|
||||
|
||||
rate, err := service.db.Transactions().GetLockedRate(ctx, id)
|
||||
if err != nil {
|
||||
service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
cents := convertToCents(rate, &info.Received)
|
||||
|
||||
if cents >= service.MinCoinPayment {
|
||||
err = service.Accounts().Coupons().AddPromotionalCoupon(ctx, userID)
|
||||
if err != nil {
|
||||
service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return service.db.Transactions().Update(ctx, updates, applies)
|
||||
@ -429,12 +409,6 @@ func (service *Service) PrepareInvoiceProjectRecords(ctx context.Context, period
|
||||
return Error.New("allowed for past periods only")
|
||||
}
|
||||
|
||||
// just in case there are users who do not have active coupons, apply free tier coupons for current billing period
|
||||
err = service.addFreeTierCoupons(ctx, end)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
var numberOfCustomers, numberOfRecords, numberOfCouponsUsages int
|
||||
customersPage, err := service.db.Customers().List(ctx, 0, service.listingLimit, end)
|
||||
if err != nil {
|
||||
@ -776,71 +750,6 @@ func (service *Service) InvoiceApplyCoupons(ctx context.Context, period time.Tim
|
||||
|
||||
service.log.Info("Number of processed coupons usages.", zap.Int("Coupons Usages", couponsUsages))
|
||||
|
||||
// now that coupons for this billing period are applied, add coupons, where applicable, for next billing period
|
||||
err = service.addFreeTierCoupons(ctx, end)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addFreeTierCoupons iterates over all customers and gives a new coupon to users with expired or used coupons.
|
||||
func (service *Service) addFreeTierCoupons(ctx context.Context, end time.Time) error {
|
||||
service.log.Info("Populating promotional coupons for users without active coupons...")
|
||||
|
||||
couponValue := service.CouponValue
|
||||
var couponDuration *int
|
||||
if service.CouponDuration != nil {
|
||||
d := int(*service.CouponDuration)
|
||||
couponDuration = &d
|
||||
}
|
||||
|
||||
cusPage, err := service.db.Customers().List(ctx, 0, service.listingLimit, end)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
userIDList := make([]uuid.UUID, 0, service.listingLimit)
|
||||
for _, cus := range cusPage.Customers {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
userIDList = append(userIDList, cus.UserID)
|
||||
}
|
||||
err = service.db.Coupons().PopulatePromotionalCoupons(ctx, userIDList, couponDuration, couponValue, 0)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
for cusPage.Next {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
cusPage, err = service.db.Customers().List(ctx, cusPage.NextOffset, service.listingLimit, end)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
userIDList := make([]uuid.UUID, 0, service.listingLimit)
|
||||
for _, cus := range cusPage.Customers {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
userIDList = append(userIDList, cus.UserID)
|
||||
}
|
||||
|
||||
err = service.db.Coupons().PopulatePromotionalCoupons(ctx, userIDList, couponDuration, couponValue, 0)
|
||||
if err != nil {
|
||||
return Error.Wrap(err)
|
||||
}
|
||||
|
||||
}
|
||||
service.log.Info("Done populating promotional coupons.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,6 @@ import (
|
||||
"storj.io/storj/satellite/console"
|
||||
"storj.io/storj/satellite/metabase"
|
||||
"storj.io/storj/satellite/payments"
|
||||
"storj.io/storj/satellite/payments/paymentsconfig"
|
||||
"storj.io/storj/satellite/payments/stripecoinpayments"
|
||||
)
|
||||
|
||||
@ -302,19 +301,11 @@ func TestService_InvoiceUserWithManyCoupons(t *testing.T) {
|
||||
|
||||
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
// InvoiceApplyCoupons should apply a new promotional coupon after all the other coupons are used up
|
||||
// So we should expect the number of coupon usages to be one less than the total number of coupons.
|
||||
require.Equal(t, len(coupons)-1, len(couponsPage.Usages))
|
||||
require.Equal(t, len(coupons), len(couponsPage.Usages))
|
||||
|
||||
// We should expect one active coupon (newly added at the end of InvoiceApplyCoupons)
|
||||
// Everything else should be used.
|
||||
activeCount := 0
|
||||
for _, coupon := range coupons {
|
||||
if coupon.Status == payments.CouponActive {
|
||||
activeCount++
|
||||
}
|
||||
require.Equal(t, payments.CouponUsed, coupon.Status)
|
||||
}
|
||||
require.Equal(t, 1, activeCount)
|
||||
|
||||
couponsPage, err = satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
|
||||
require.NoError(t, err)
|
||||
@ -523,18 +514,8 @@ func TestService_CouponStatus(t *testing.T) {
|
||||
|
||||
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
|
||||
require.NoError(t, err, errTag)
|
||||
// If the coupon is expected to be active, there should only be one. Otherwise (if expired or used), InvoiceApplyCoupons should have added a new active coupon.
|
||||
if tt.expectedStatus == payments.CouponActive {
|
||||
require.Len(t, coupons, 1, errTag)
|
||||
assert.Equal(t, tt.expectedStatus, coupons[0].Status, errTag)
|
||||
} else {
|
||||
// One of the coupons must be active - verify that the other one matches the expected status for this test.
|
||||
if coupons[0].Status == payments.CouponActive {
|
||||
assert.Equal(t, tt.expectedStatus, coupons[1].Status, errTag)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedStatus, coupons[0].Status, errTag)
|
||||
}
|
||||
}
|
||||
require.Len(t, coupons, 1, errTag)
|
||||
assert.Equal(t, tt.expectedStatus, coupons[0].Status, errTag)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -659,101 +640,3 @@ func TestService_InvoiceItemsFromProjectRecord(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestService_ApplyFreeTierCoupons ensures that free tier coupons are properly added at the beginning and end of invoice generation.
|
||||
func TestService_ApplyFreeTierCoupons(t *testing.T) {
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
|
||||
Reconfigure: testplanet.Reconfigure{
|
||||
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
||||
config.Payments.CouponValue = 5
|
||||
config.Payments.CouponDuration = paymentsconfig.CouponDuration{
|
||||
Enabled: true,
|
||||
BillingPeriods: 1,
|
||||
}
|
||||
},
|
||||
},
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
satellite := planet.Satellites[0]
|
||||
|
||||
// User 1 should have one coupon automatically applied
|
||||
user1, err := satellite.AddUser(ctx, console.CreateUser{
|
||||
FullName: "testuser1",
|
||||
Email: "test1@test",
|
||||
}, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = satellite.AddProject(ctx, user1.ID, "testproject1")
|
||||
require.NoError(t, err)
|
||||
|
||||
coupons, err := satellite.API.Payments.Accounts.Coupons().ListByUserID(ctx, user1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 1)
|
||||
|
||||
// We delete the automatically applied coupon for user 2
|
||||
user2, err := satellite.AddUser(ctx, console.CreateUser{
|
||||
FullName: "testuser2",
|
||||
Email: "test2@test",
|
||||
}, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = satellite.AddProject(ctx, user2.ID, "testproject2")
|
||||
require.NoError(t, err)
|
||||
|
||||
coupons, err = satellite.API.Payments.Accounts.Coupons().ListByUserID(ctx, user2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 1)
|
||||
for _, coupon := range coupons {
|
||||
err = satellite.DB.StripeCoinPayments().Coupons().Delete(ctx, coupon.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
coupons, err = satellite.API.Payments.Accounts.Coupons().ListByUserID(ctx, user2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 0)
|
||||
|
||||
// Now, let's run PrepareInvoiceProjectRecords and InvoiceApplyCoupons - it should attempt to add the promotional
|
||||
// coupon at the beginning and the end.
|
||||
// This means that by the end, user 1 should have their default coupon expired, and a new coupon applied.
|
||||
// As for user 2, they should have a new coupon applied at the beginning, it should be expired, and a new coupon should be added at the end.
|
||||
// In other words, by the end, users 1 and 2 should both have two coupons: one expired, and one active.
|
||||
|
||||
// pick a specific date so that it doesn't fail if it's the last day of the month
|
||||
// keep month + 1 because user needs to be created before calculation
|
||||
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
satellite.API.Payments.Service.SetNow(func() time.Time {
|
||||
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
||||
})
|
||||
err = satellite.API.Payments.Service.PrepareInvoiceProjectRecords(ctx, period)
|
||||
require.NoError(t, err)
|
||||
|
||||
// now that PrepareInvoiceProjectRecords has run, user 2 should have a single expired coupon
|
||||
coupons, err = satellite.API.Payments.Accounts.Coupons().ListByUserID(ctx, user2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 1)
|
||||
require.Equal(t, payments.CouponExpired, coupons[0].Status)
|
||||
|
||||
err = satellite.API.Payments.Service.InvoiceApplyCoupons(ctx, period)
|
||||
require.NoError(t, err)
|
||||
|
||||
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 2)
|
||||
if coupons[0].Status == payments.CouponExpired {
|
||||
require.Equal(t, payments.CouponActive, coupons[1].Status)
|
||||
} else {
|
||||
require.Equal(t, payments.CouponActive, coupons[0].Status)
|
||||
require.Equal(t, payments.CouponExpired, coupons[1].Status)
|
||||
}
|
||||
|
||||
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, coupons, 2)
|
||||
if coupons[0].Status == payments.CouponExpired {
|
||||
require.Equal(t, payments.CouponActive, coupons[1].Status)
|
||||
} else {
|
||||
require.Equal(t, payments.CouponActive, coupons[0].Status)
|
||||
require.Equal(t, payments.CouponExpired, coupons[1].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -580,6 +580,9 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
|
||||
# amount of time we wait before running next conversion rates update loop
|
||||
# payments.stripe-coin-payments.conversion-rates-cycle-interval: 10m0s
|
||||
|
||||
# stripe free tier coupon ID
|
||||
# payments.stripe-coin-payments.stripe-free-tier-coupon-id: ""
|
||||
|
||||
# stripe API public key
|
||||
# payments.stripe-coin-payments.stripe-public-key: ""
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user