From 4c0817bcfb1bbd8f5a62d49ebe1d8374e76ccebb Mon Sep 17 00:00:00 2001 From: Moby von Briesen Date: Tue, 27 Apr 2021 17:20:53 -0400 Subject: [PATCH] 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 --- satellite/console/consoleweb/server.go | 40 ----- satellite/console/service.go | 12 -- satellite/payments/coupons.go | 1 - satellite/payments/paymentsconfig/config.go | 4 +- .../stripecoinpayments/coupons_test.go | 144 +++++++++++++++--- .../payments/stripecoinpayments/service.go | 57 +++++++ .../stripecoinpayments/service_test.go | 26 +++- satellite/satellitedb/coupons.go | 5 +- scripts/testdata/satellite-config.yaml.lock | 4 +- 9 files changed, 210 insertions(+), 83 deletions(-) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 224c43a60..fac64bf10 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -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() diff --git a/satellite/console/service.go b/satellite/console/service.go index 4813056e2..f6ec6e7ec 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -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) diff --git a/satellite/payments/coupons.go b/satellite/payments/coupons.go index ea8b66116..c2128a633 100644 --- a/satellite/payments/coupons.go +++ b/satellite/payments/coupons.go @@ -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 ) diff --git a/satellite/payments/paymentsconfig/config.go b/satellite/payments/paymentsconfig/config.go index 0869b6454..b12c9bf4b 100644 --- a/satellite/payments/paymentsconfig/config.go +++ b/satellite/payments/paymentsconfig/config.go @@ -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"` diff --git a/satellite/payments/stripecoinpayments/coupons_test.go b/satellite/payments/stripecoinpayments/coupons_test.go index a6afad4f9..e5ef241c4 100644 --- a/satellite/payments/stripecoinpayments/coupons_test.go +++ b/satellite/payments/stripecoinpayments/coupons_test.go @@ -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) }) }) } diff --git a/satellite/payments/stripecoinpayments/service.go b/satellite/payments/stripecoinpayments/service.go index 7d907030b..bde2e714a 100644 --- a/satellite/payments/stripecoinpayments/service.go +++ b/satellite/payments/stripecoinpayments/service.go @@ -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 } diff --git a/satellite/payments/stripecoinpayments/service_test.go b/satellite/payments/stripecoinpayments/service_test.go index a7716142f..1d3976702 100644 --- a/satellite/payments/stripecoinpayments/service_test.go +++ b/satellite/payments/stripecoinpayments/service_test.go @@ -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) + } + } } }) } diff --git a/satellite/satellitedb/coupons.go b/satellite/satellitedb/coupons.go index 99d65a4c1..8da319c39 100644 --- a/satellite/satellitedb/coupons.go +++ b/satellite/satellitedb/coupons.go @@ -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 } diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 38b75fe28..b413f171e 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -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"