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:
parent
2e5e4cb64f
commit
4c0817bcfb
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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"`
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
4
scripts/testdata/satellite-config.yaml.lock
vendored
4
scripts/testdata/satellite-config.yaml.lock
vendored
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user