satellite/accountfreeze: mark billing frozen users for deletion

This change modifies the billing freeze chore to set pending deletion
status to users who are still frozen after a grace period.
It also modifies the chore to skip already deleted users.

Issue: https://github.com/storj/storj/issues/6303
https://github.com/storj/storj-private/issues/453

Change-Id: I4d0e7dd904463e99424372dc9ac81b71c0bc6e28
This commit is contained in:
Wilfred Asomani 2023-10-04 09:48:21 +00:00 committed by Storj Robot
parent ee2b6e66de
commit 594e63f13a
5 changed files with 146 additions and 36 deletions

View File

@ -276,6 +276,16 @@ func (s *AccountFreezeService) BillingUnfreezeUser(ctx context.Context, userID u
return err
}
if user.Status == PendingDeletion {
status := Active
err = s.usersDB.Update(ctx, userID, UpdateUserRequest{
Status: &status,
})
if err != nil {
return ErrAccountFreeze.Wrap(errs.Combine(ErrFreezeUserStatusUpdate, err))
}
}
s.tracker.TrackAccountUnfrozen(userID, user.Email)
return nil
}

View File

@ -108,7 +108,20 @@ func TestAccountBillingUnFreeze(t *testing.T) {
require.NoError(t, projectsDB.UpdateUsageLimits(ctx, proj.ID, projLimits))
require.NoError(t, service.BillingFreezeUser(ctx, user.ID))
status := console.PendingDeletion
err = usersDB.Update(ctx, user.ID, console.UpdateUserRequest{
Status: &status,
})
require.NoError(t, err)
user, err = usersDB.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, status, user.Status)
require.NoError(t, service.BillingUnfreezeUser(ctx, user.ID))
user, err = usersDB.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, console.Active, user.Status)
user, err = usersDB.Get(ctx, user.ID)
require.NoError(t, err)

View File

@ -29,11 +29,12 @@ var (
// Config contains configurable values for account freeze chore.
type Config struct {
Enabled bool `help:"whether to run this chore." default:"false"`
Interval time.Duration `help:"How often to run this chore, which is how often unpaid invoices are checked." default:"24h"`
GracePeriod time.Duration `help:"How long to wait between a warning event and freezing an account." default:"360h"`
PriceThreshold int64 `help:"The failed invoice amount (in cents) beyond which an account will not be frozen" default:"100000"`
ExcludeStorjscan bool `help:"whether to exclude storjscan-paying users from automatic warn/freeze" default:"false"`
Enabled bool `help:"whether to run this chore." default:"false"`
Interval time.Duration `help:"How often to run this chore, which is how often unpaid invoices are checked." default:"24h"`
BillingWarnGracePeriod time.Duration `help:"How long to wait between a billing warning event and billing freezing an account." default:"360h"`
BillingFreezeGracePeriod time.Duration `help:"How long to wait between a billing freeze event and setting pending deletion account status." default:"1440h"`
PriceThreshold int64 `help:"The failed invoice amount (in cents) beyond which an account will not be frozen" default:"100000"`
ExcludeStorjscan bool `help:"whether to exclude storjscan-paying users from automatic warn/freeze" default:"false"`
}
// Chore is a chore that checks for unpaid invoices and potentially freezes corresponding accounts.
@ -73,15 +74,15 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
return chore.Loop.Run(ctx, func(ctx context.Context) (err error) {
chore.attemptFreezeWarn(ctx)
chore.attemptBillingFreezeWarn(ctx)
chore.attemptUnfreezeUnwarn(ctx)
chore.attemptBillingUnfreezeUnwarn(ctx)
return nil
})
}
func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
func (chore *Chore) attemptBillingFreezeWarn(ctx context.Context) {
invoices, err := chore.payments.Invoices().ListFailed(ctx, nil)
if err != nil {
chore.log.Error("Could not list invoices", zap.Error(Error.Wrap(err)))
@ -90,8 +91,8 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
chore.log.Info("failed invoices found", zap.Int("count", len(invoices)))
userMap := make(map[uuid.UUID]struct{})
frozenMap := make(map[uuid.UUID]struct{})
warnedMap := make(map[uuid.UUID]struct{})
billingFrozenMap := make(map[uuid.UUID]struct{})
billingWarnedMap := make(map[uuid.UUID]struct{})
bypassedLargeMap := make(map[uuid.UUID]struct{})
bypassedTokenMap := make(map[uuid.UUID]struct{})
@ -114,7 +115,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
continue
}
debugLog := func(message string) {
infoLog := func(message string) {
chore.log.Info(message,
zap.String("process", "billing freeze/warn"),
zap.String("invoiceID", invoice.ID),
@ -141,12 +142,17 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
continue
}
if user.Status == console.Deleted {
errorLog("Ignoring invoice; account already deleted", errs.New("user deleted, but has unpaid invoices"))
continue
}
if invoice.Amount > chore.config.PriceThreshold {
if _, ok := bypassedLargeMap[userID]; ok {
continue
}
bypassedLargeMap[userID] = struct{}{}
debugLog("Ignoring invoice; amount exceeds threshold")
infoLog("Ignoring invoice; amount exceeds threshold")
chore.analytics.TrackLargeUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
@ -169,7 +175,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
}
if len(cachedPayments) > 0 {
bypassedTokenMap[userID] = struct{}{}
debugLog("Ignoring invoice; TX exists in storjscan")
infoLog("Ignoring invoice; TX exists in storjscan")
chore.analytics.TrackStorjscanUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
@ -183,7 +189,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
}
if freezes.ViolationFreeze != nil {
debugLog("Ignoring invoice; account already frozen due to violation")
infoLog("Ignoring invoice; account already frozen due to violation")
chore.analytics.TrackViolationFrozenUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
@ -191,7 +197,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
// try to pay the invoice before freezing/warning.
err = chore.payments.Invoices().AttemptPayOverdueInvoices(ctx, userID)
if err == nil {
debugLog("Ignoring invoice; Payment attempt successful")
infoLog("Ignoring invoice; Payment attempt successful")
if freezes.BillingWarning != nil {
err = chore.freezeService.BillingUnWarnUser(ctx, userID)
@ -212,7 +218,37 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
}
if freezes.BillingFreeze != nil {
debugLog("Ignoring invoice; account already billing frozen")
if chore.nowFn().Sub(freezes.BillingFreeze.CreatedAt) > chore.config.BillingFreezeGracePeriod {
if user.Status == console.PendingDeletion {
infoLog("Ignoring invoice; account already marked for deletion")
continue
}
// check if the invoice has been paid by the time the chore gets here.
isPaid, err := checkInvPaid(invoice.ID)
if err != nil {
errorLog("Could not verify invoice status", err)
continue
}
if isPaid {
infoLog("Ignoring invoice; payment already made")
continue
}
status := console.PendingDeletion
err = chore.usersDB.Update(ctx, userID, console.UpdateUserRequest{
Status: &status,
})
if err != nil {
errorLog("Could not mark account for deletion", err)
continue
}
infoLog("account marked for deletion")
continue
}
infoLog("Ignoring invoice; account already billing frozen")
continue
}
@ -224,7 +260,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
continue
}
if isPaid {
debugLog("Ignoring invoice; payment already made")
infoLog("Ignoring invoice; payment already made")
continue
}
err = chore.freezeService.BillingWarnUser(ctx, userID)
@ -232,12 +268,12 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
errorLog("Could not add billing warning event", err)
continue
}
debugLog("user billing warned")
warnedMap[userID] = struct{}{}
infoLog("user billing warned")
billingWarnedMap[userID] = struct{}{}
continue
}
if chore.nowFn().Sub(freezes.BillingWarning.CreatedAt) > chore.config.GracePeriod {
if chore.nowFn().Sub(freezes.BillingWarning.CreatedAt) > chore.config.BillingWarnGracePeriod {
// check if the invoice has been paid by the time the chore gets here.
isPaid, err := checkInvPaid(invoice.ID)
if err != nil {
@ -245,7 +281,7 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
continue
}
if isPaid {
debugLog("Ignoring invoice; payment already made")
infoLog("Ignoring invoice; payment already made")
continue
}
err = chore.freezeService.BillingFreezeUser(ctx, userID)
@ -253,22 +289,22 @@ func (chore *Chore) attemptFreezeWarn(ctx context.Context) {
errorLog("Could not billing freeze account", err)
continue
}
debugLog("user billing frozen")
frozenMap[userID] = struct{}{}
infoLog("user billing frozen")
billingFrozenMap[userID] = struct{}{}
}
}
chore.log.Info("billing freezing/warning executed",
zap.Int("total invoices", len(invoices)),
zap.Int("user total", len(userMap)),
zap.Int("total warned", len(warnedMap)),
zap.Int("total frozen", len(frozenMap)),
zap.Int("total billing warned", len(billingWarnedMap)),
zap.Int("total billing frozen", len(billingFrozenMap)),
zap.Int("total bypassed due to size of invoice", len(bypassedLargeMap)),
zap.Int("total bypassed due to storjscan payments", len(bypassedTokenMap)),
)
}
func (chore *Chore) attemptUnfreezeUnwarn(ctx context.Context) {
func (chore *Chore) attemptBillingUnfreezeUnwarn(ctx context.Context) {
cursor := console.FreezeEventsCursor{
Limit: 100,
}
@ -300,14 +336,28 @@ func (chore *Chore) attemptUnfreezeUnwarn(ctx context.Context) {
zap.Error(Error.Wrap(err)),
)
}
if event.Type == console.ViolationFreeze {
chore.log.Info("Skipping violation freeze event",
infoLog := func(message string) {
chore.log.Info(message,
zap.String("process", "billing unfreeze/unwarn"),
zap.Any("userID", event.UserID),
zap.String("eventType", event.Type.String()),
zap.Error(Error.Wrap(err)),
)
}
if event.Type == console.ViolationFreeze {
infoLog("Skipping violation freeze event")
continue
}
user, err := chore.usersDB.Get(ctx, event.UserID)
if err != nil {
errorLog("Could not get user", err)
continue
}
if user.Status == console.Deleted || user.Status == console.PendingDeletion {
infoLog("Skipping event; account already deleted or pending deletion")
continue
}
@ -332,7 +382,7 @@ func (chore *Chore) attemptUnfreezeUnwarn(ctx context.Context) {
errorLog("Could not billing unfreeze user", err)
}
unfrozenCount++
} else {
} else if event.Type == console.BillingWarning {
err = chore.freezeService.BillingUnWarnUser(ctx, event.UserID)
if err != nil {
errorLog("Could not billing unwarn user", err)

View File

@ -141,7 +141,7 @@ func TestAutoFreezeChore(t *testing.T) {
require.NotNil(t, freezes.ViolationFreeze)
})
t.Run("No freeze event for paid invoice", func(t *testing.T) {
t.Run("No billing freeze event for paid invoice", func(t *testing.T) {
// AnalyticsMock tests that events are sent once.
service.TestChangeFreezeTracker(newFreezeTrackerMock(t))
_, err := stripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
@ -238,7 +238,7 @@ func TestAutoFreezeChore(t *testing.T) {
require.Nil(t, freezes.ViolationFreeze)
chore.TestSetNow(func() time.Time {
// current date is now after grace period
// current date is now after billing warn grace period
return time.Now().AddDate(0, 0, 50)
})
chore.Loop.TriggerWait()
@ -248,7 +248,19 @@ func TestAutoFreezeChore(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, freezes.BillingFreeze)
// Pay invoice so it doesn't show up in the next test.
chore.TestSetNow(func() time.Time {
// current date is now after billing freeze grace period
return time.Now().AddDate(0, 0, 70)
})
chore.Loop.TriggerWait()
// user should be marked for deletion after the grace period
// after being frozen
userPD, err := usersDB.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, console.PendingDeletion, userPD.Status)
// Pay invoice so user qualifies to be removed from billing freeze.
inv, err = stripeClient.Invoices().Pay(inv.ID, &stripe.InvoicePayParams{
Params: stripe.Params{Context: ctx},
PaymentMethod: stripe.String(stripe1.MockInvoicesPaySuccess),
@ -256,9 +268,31 @@ func TestAutoFreezeChore(t *testing.T) {
require.NoError(t, err)
require.Equal(t, stripe.InvoiceStatusPaid, inv.Status)
// set user status to deleted
status := console.Deleted
err = usersDB.Update(ctx, user.ID, console.UpdateUserRequest{
Status: &status,
})
require.NoError(t, err)
chore.Loop.TriggerWait()
// deleted user should be skipped, hence would not exist the
// billing freeze status.
isFrozen, err := service.IsUserBillingFrozen(ctx, user.ID)
require.NoError(t, err)
require.True(t, isFrozen)
// unfreeze user so they're not frozen in the next test.
err = service.BillingUnfreezeUser(ctx, user.ID)
require.NoError(t, err)
// set user status back to active
status = console.Active
err = usersDB.Update(ctx, user.ID, console.UpdateUserRequest{
Status: &status,
})
require.NoError(t, err)
})
t.Run("No freeze event for failed invoice (successful later payment attempt)", func(t *testing.T) {

View File

@ -1,12 +1,15 @@
# How long to wait between a billing freeze event and setting pending deletion account status.
# account-freeze.billing-freeze-grace-period: 1440h0m0s
# How long to wait between a billing warning event and billing freezing an account.
# account-freeze.billing-warn-grace-period: 360h0m0s
# whether to run this chore.
# account-freeze.enabled: false
# whether to exclude storjscan-paying users from automatic warn/freeze
# account-freeze.exclude-storjscan: false
# How long to wait between a warning event and freezing an account.
# account-freeze.grace-period: 360h0m0s
# How often to run this chore, which is how often unpaid invoices are checked.
# account-freeze.interval: 24h0m0s