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:
Moby von Briesen 2021-05-10 19:12:05 +02:00 committed by Maximillian von Briesen
parent 92226d8ddb
commit 02fc87e98b
5 changed files with 17 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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

View File

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