satellite/payments: Populate new coupons during invoice generation

The previously configured never-expiring coupon does not refill every
month. Eventually, even though it never expires, it will run out. This
commit makes several small changes to address this issue for the free
tier:
* Change the config for the promotional coupon to be $1.65 for 1 month
(the change from $10 to $1.65 is due to our recent pricing changes)
* Update PopulatePromotionalCoupons (PPC for brevity) to add promotional
coupons to users with expired and consumed coupons (all users with a
project and no active coupons should get a new coupon when PPC is called)
* Call PPC at the end of the `create-invoice-coupons` stage of invoice
generation - after current coupons are processed and expired/exhausted.
* Remove legacy admin functionality for PPC from satellite/console - we
do not currently use it, but if we did, it should be in satellite/admin
instead.

Change-Id: I77727b97bef972df32ebb23cdc05055827076e2a
This commit is contained in:
Moby von Briesen 2021-04-27 17:20:53 -04:00 committed by Maximillian von Briesen
parent 2e5e4cb64f
commit 4c0817bcfb
9 changed files with 210 additions and 83 deletions

View File

@ -202,7 +202,6 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail
fs := http.FileServer(http.Dir(server.config.StaticDir))
router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler)
router.HandleFunc("/populate-promotional-coupons", server.populatePromotionalCoupons).Methods(http.MethodPost)
router.HandleFunc("/robots.txt", server.seoHandler)
router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler)))
@ -529,45 +528,6 @@ func (server *Server) createRegistrationTokenHandler(w http.ResponseWriter, r *h
response.Secret = token.Secret.String()
}
// populatePromotionalCoupons is web app http handler function for populating promotional coupons.
func (server *Server) populatePromotionalCoupons(w http.ResponseWriter, r *http.Request) {
var err error
var ctx context.Context
defer mon.Task()(&ctx)(&err)
handleError := func(status int, err error) {
w.WriteHeader(status)
w.Header().Set(contentType, applicationJSON)
var response struct {
Error string `json:"error"`
}
response.Error = err.Error()
if err := json.NewEncoder(w).Encode(response); err != nil {
server.log.Error("failed to write json error response", zap.Error(Error.Wrap(err)))
}
}
ctx = r.Context()
equality := subtle.ConstantTimeCompare(
[]byte(r.Header.Get("Authorization")),
[]byte(server.config.AuthToken),
)
if equality != 1 {
handleError(http.StatusUnauthorized, errs.New("unauthorized"))
return
}
if err = server.service.Payments().PopulatePromotionalCoupons(ctx); err != nil {
handleError(http.StatusInternalServerError, err)
return
}
}
// accountActivationHandler is web app http handler function.
func (server *Server) accountActivationHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -20,7 +20,6 @@ import (
"golang.org/x/crypto/bcrypt"
"storj.io/common/macaroon"
"storj.io/common/memory"
"storj.io/common/storj"
"storj.io/common/uuid"
"storj.io/storj/satellite/accounting"
@ -482,17 +481,6 @@ func (paymentService PaymentsService) checkProjectInvoicingStatus(ctx context.Co
return paymentService.service.accounts.CheckProjectInvoicingStatus(ctx, projectID)
}
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have
// a project, payment method and do not have a promotional coupon yet.
// And updates project limits to selected size.
// This functionality is deprecated and will be removed.
func (paymentService PaymentsService) PopulatePromotionalCoupons(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
duration := 2
return Error.Wrap(paymentService.service.accounts.Coupons().PopulatePromotionalCoupons(ctx, &duration, 5500, memory.TB))
}
// AddPromotionalCoupon creates new coupon for specified user.
func (paymentService PaymentsService) AddPromotionalCoupon(ctx context.Context, userID uuid.UUID) (err error) {
defer mon.Task()(&ctx, userID)(&err)

View File

@ -68,7 +68,6 @@ type CouponType int
const (
// CouponTypePromotional defines that this coupon is a promotional coupon.
// Promotional coupon is added only once per account.
CouponTypePromotional CouponType = 0
)

View File

@ -18,8 +18,8 @@ type Config struct {
EgressTBPrice string `help:"price user should pay for each TB of egress" default:"7"`
ObjectPrice string `help:"price user should pay for each object stored in network per month" default:"0.0000022"`
BonusRate int64 `help:"amount of percents that user will earn as bonus credits by depositing in STORJ tokens" default:"10"`
CouponValue int64 `help:"coupon value in cents" default:"1000"`
CouponDuration CouponDuration `help:"duration a new coupon is valid in months/billing cycles. An empty string means the coupon never expires" default:""`
CouponValue int64 `help:"coupon value in cents" default:"165"`
CouponDuration CouponDuration `help:"duration a new coupon is valid in months/billing cycles. An empty string means the coupon never expires" default:"1"`
CouponProjectLimit memory.Size `help:"project limit to which increase to after applying the coupon, 0 B means not changing it from the default" default:"0 B"`
MinCoinPayment int64 `help:"minimum value of coin payments in cents before coupon is applied" default:"1000"`
NodeEgressBandwidthPrice int64 `help:"price node receive for storing TB of egress in cents" default:"2000"`

View File

@ -89,18 +89,20 @@ func TestCouponRepository(t *testing.T) {
// TestPopulatePromotionalCoupons is a test for PopulatePromotionalCoupons function
// that creates coupons with predefined values for each of user (from arguments) that have a project
// and that don't have a promotional coupon yet. Also it updates limits of selected projects to 1TB.
// and that don't have a valid promotional coupon yet. Also it updates limits of selected projects to 1TB.
// Because the coupon should be added to a project, we select the first project of the user.
// In this test i have next test cases:
// In this test we have the following test cases:
// 1. Activated user, 2 projects, without coupon. For this case we should add new coupon to his first project.
// 2. Activated user, 1 project, without coupon.
// 2. Activated user, 1 project, without coupon. Coupon should be added.
// 3. Activated user without project. Coupon should not be added.
// 4. User with inactive account. Coupon should not be added.
// 5. Activated user with project and coupon. Coupon should not be added.
// 6. Next step - is populating coupons for all 5 users. Only 2 coupons should be added.
// 7. Creating new user with project.
// 8. Populating coupons again. For 6 users above. Only 1 new coupon should be added.
// Three new coupons total should be added by 2 runs of PopulatePromotionalCoupons method.
// 6. Activated user with project and expired coupon. Coupon should be added.
// 7. Activated user with project and fully consumed, non-expired coupon. Coupon should be added.
// 8. Next step - is populating coupons for all 7 users. 4 coupons should be added.
// 9. Creating new user with project.
// 10. Populating coupons again. For 8 users above. Only 1 new coupon should be added.
// Five new coupons total should be added by 2 runs of PopulatePromotionalCoupons method.
func TestPopulatePromotionalCoupons(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
usersRepo := db.Console().Users()
@ -180,6 +182,36 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
err = usersRepo.Update(ctx, user5)
require.NoError(t, err)
// activated user with proj and expired coupon. New coupon should be added.
user6, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user6",
ShortName: "",
Email: "test6@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user6.Status = console.Active
err = usersRepo.Update(ctx, user6)
require.NoError(t, err)
// activated user with proj and non-expired but consumed coupon. New coupon should be added.
user7, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user7",
ShortName: "",
Email: "test7@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user7.Status = console.Active
err = usersRepo.Update(ctx, user7)
require.NoError(t, err)
// creating projects for users above.
proj1, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
@ -206,6 +238,31 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 5",
Description: "descr 4",
OwnerID: user5.ID,
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 6",
Description: "descr 5",
OwnerID: user6.ID,
})
require.NoError(t, err)
_, err = projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 7",
Description: "descr 5",
OwnerID: user7.ID,
})
require.NoError(t, err)
// add coupons to users who should have them before PopulatePromotionalCoupons
duration := 2
couponID := testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.Coupon{
@ -219,26 +276,50 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
})
require.NoError(t, err)
couponID = testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.Coupon{
ID: couponID,
UserID: user6.ID,
Amount: 1000,
Duration: &duration,
Description: "qw",
Type: payments.CouponTypePromotional,
Status: payments.CouponExpired,
})
require.NoError(t, err)
couponID = testrand.UUID()
_, err = couponsRepo.Insert(ctx, payments.Coupon{
ID: couponID,
UserID: user7.ID,
Amount: 1000,
Duration: nil,
Description: "qw",
Type: payments.CouponTypePromotional,
Status: payments.CouponUsed,
})
require.NoError(t, err)
// creating new users and projects to test that multiple execution of populate method wont generate extra coupons.
user6, err := usersRepo.Insert(ctx, &console.User{
user8, err := usersRepo.Insert(ctx, &console.User{
ID: testrand.UUID(),
FullName: "user6",
FullName: "user8",
ShortName: "",
Email: "test6@example.com",
Email: "test8@example.com",
PasswordHash: []byte("123qwe"),
})
require.NoError(t, err)
user6.Status = console.Active
user8.Status = console.Active
err = usersRepo.Update(ctx, user6)
err = usersRepo.Update(ctx, user8)
require.NoError(t, err)
proj5, err := projectsRepo.Insert(ctx, &console.Project{
proj4, err := projectsRepo.Insert(ctx, &console.Project{
ID: testrand.UUID(),
Name: "proj 1 of user 6",
Description: "descr 6",
OwnerID: user6.ID,
Name: "proj 1 of user 8",
Description: "descr 7",
OwnerID: user8.ID,
})
if err != nil {
require.NoError(t, err)
@ -251,6 +332,8 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
user3.ID,
user4.ID,
user5.ID,
user6.ID,
user7.ID,
}
duration := 2
err := couponsRepo.PopulatePromotionalCoupons(ctx, usersIds, &duration, 5500, memory.TB)
@ -287,7 +370,18 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
user5Coupons, err := couponsRepo.ListByUserID(ctx, user5.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user5Coupons))
require.Equal(t, "qw", user5Coupons[0].Description)
user6Coupons, err := couponsRepo.ListByUserID(ctx, user6.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user6Coupons))
user7Coupons, err := couponsRepo.ListByUserID(ctx, user7.ID)
require.NoError(t, err)
require.Equal(t, 2, len(user7Coupons))
user8Coupons, err := couponsRepo.ListByUserID(ctx, user8.ID)
require.NoError(t, err)
require.Equal(t, 0, len(user8Coupons))
})
t.Run("second population", func(t *testing.T) {
@ -298,6 +392,8 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
user4.ID,
user5.ID,
user6.ID,
user7.ID,
user8.ID,
}
duration := 2
err := couponsRepo.PopulatePromotionalCoupons(ctx, usersIds, &duration, 5500, memory.TB)
@ -338,11 +434,19 @@ func TestPopulatePromotionalCoupons(t *testing.T) {
user6Coupons, err := couponsRepo.ListByUserID(ctx, user6.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user6Coupons))
require.Equal(t, 2, len(user6Coupons))
proj5Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj5.ID)
user7Coupons, err := couponsRepo.ListByUserID(ctx, user7.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj5Usage)
require.Equal(t, 2, len(user7Coupons))
user8Coupons, err := couponsRepo.ListByUserID(ctx, user8.ID)
require.NoError(t, err)
require.Equal(t, 1, len(user8Coupons))
proj4Usage, err := usageRepo.GetProjectStorageLimit(ctx, proj4.ID)
require.NoError(t, err)
require.Equal(t, memory.TB.Int64(), *proj4Usage)
})
})
}

View File

@ -20,6 +20,7 @@ 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"
@ -767,6 +768,62 @@ func (service *Service) InvoiceApplyCoupons(ctx context.Context, period time.Tim
}
service.log.Info("Number of processed coupons usages.", zap.Int("Coupons Usages", couponsUsages))
// iterate over all customers and give new coupon to users with expired or exhausted coupons
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, 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, 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

@ -301,11 +301,19 @@ func TestService_InvoiceUserWithManyCoupons(t *testing.T) {
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, len(coupons), len(couponsPage.Usages))
// 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))
// We should expect one active coupon (newly added at the end of InvoiceApplyCoupons)
// Everything else should be used.
activeCount := 0
for _, coupon := range coupons {
require.Equal(t, payments.CouponUsed, coupon.Status)
if coupon.Status == payments.CouponActive {
activeCount++
}
}
require.Equal(t, 1, activeCount)
couponsPage, err = satellite.DB.StripeCoinPayments().Coupons().ListUnapplied(ctx, 0, 40, start)
require.NoError(t, err)
@ -514,8 +522,18 @@ func TestService_CouponStatus(t *testing.T) {
coupons, err = satellite.DB.StripeCoinPayments().Coupons().ListByUserID(ctx, user.ID)
require.NoError(t, err, errTag)
require.Len(t, coupons, 1, errTag)
assert.Equal(t, tt.expectedStatus, coupons[0].Status, 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)
}
}
}
})
}

View File

@ -384,6 +384,7 @@ func couponUsageFromDbxSlice(couponUsageDbx *dbx.CouponUsage) (usage stripecoinp
// PopulatePromotionalCoupons is used to populate promotional coupons through all active users who already have a project
// and do not have a promotional coupon yet. And updates project limits to selected size.
// If projectLimit is 0, project limits are not updated.
func (coupons *coupons) PopulatePromotionalCoupons(ctx context.Context, users []uuid.UUID, duration *int, amount int64, projectLimit memory.Size) (err error) {
defer mon.Task()(&ctx, users, duration, amount, projectLimit)(&err)
@ -453,9 +454,9 @@ func (coupons *coupons) activeUserWithProjectAndWithoutCoupon(ctx context.Contex
WHERE selected_users.status = ?
) AS users_with_projects
WHERE users_with_projects.id NOT IN (
SELECT user_id FROM coupons WHERE type = ?
SELECT user_id FROM coupons WHERE type = ? AND status = ?
)
`), pgutil.ByteaArray(userIDs), console.Active, payments.CouponTypePromotional)
`), pgutil.ByteaArray(userIDs), console.Active, payments.CouponTypePromotional, payments.CouponActive)
if err != nil {
return nil, err
}

View File

@ -512,13 +512,13 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
# payments.bonus-rate: 10
# duration a new coupon is valid in months/billing cycles. An empty string means the coupon never expires
# payments.coupon-duration: ""
# payments.coupon-duration: "1"
# project limit to which increase to after applying the coupon, 0 B means not changing it from the default
# payments.coupon-project-limit: 0 B
# coupon value in cents
# payments.coupon-value: 1000
# payments.coupon-value: 165
# price user should pay for each TB of egress
# payments.egress-tb-price: "7"