storj/satellite/payments/accountfreeze/chore.go
Wilfred Asomani fd1a94a84d satellite/accountfreeze: add debug logs
A debug log has been added to the autofreeze chore to show numbers of
invoices, users involved, users frozen, users warned and users bypassed
due to larger than config amount owed.

Issue: https://github.com/storj/storj/issues/5700

Change-Id: I4e628606fe234b6d239b76167a4781ca9e276308
2023-03-28 15:30:31 +00:00

196 lines
5.8 KiB
Go

// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package accountfreeze
import (
"context"
"time"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/sync2"
"storj.io/common/uuid"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/stripecoinpayments"
)
var (
// Error is the standard error class for automatic freeze errors.
Error = errs.Class("account-freeze-chore")
mon = monkit.Package()
)
// 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:"720h"`
PriceThreshold int64 `help:"The failed invoice amount beyond which an account will not be frozen" default:"2000"`
}
// Chore is a chore that checks for unpaid invoices and potentially freezes corresponding accounts.
type Chore struct {
log *zap.Logger
freezeService *console.AccountFreezeService
analytics *analytics.Service
usersDB console.Users
payments payments.Accounts
accounts stripecoinpayments.DB
config Config
nowFn func() time.Time
Loop *sync2.Cycle
}
// NewChore is a constructor for Chore.
func NewChore(log *zap.Logger, accounts stripecoinpayments.DB, payments payments.Accounts, usersDB console.Users, freezeService *console.AccountFreezeService, analytics *analytics.Service, config Config) *Chore {
return &Chore{
log: log,
freezeService: freezeService,
analytics: analytics,
usersDB: usersDB,
accounts: accounts,
config: config,
payments: payments,
nowFn: time.Now,
Loop: sync2.NewCycle(config.Interval),
}
}
// Run runs the chore.
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) {
invoices, err := chore.payments.Invoices().ListFailed(ctx)
if err != nil {
chore.log.Error("Could not list invoices", zap.Error(Error.Wrap(err)))
return nil
}
chore.log.Debug("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{})
bypassedMap := make(map[uuid.UUID]struct{})
for _, invoice := range invoices {
userID, err := chore.accounts.Customers().GetUserID(ctx, invoice.CustomerID)
if err != nil {
chore.log.Error("Could not get userID",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Error(Error.Wrap(err)),
)
continue
}
userMap[userID] = struct{}{}
user, err := chore.usersDB.Get(ctx, userID)
if err != nil {
chore.log.Error("Could not get user",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
zap.Error(Error.Wrap(err)),
)
continue
}
if invoice.Amount > chore.config.PriceThreshold {
bypassedMap[userID] = struct{}{}
chore.log.Debug("amount due over threshold",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
)
chore.analytics.TrackLargeUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
freeze, warning, err := chore.freezeService.GetAll(ctx, userID)
if err != nil {
chore.log.Error("Could not check freeze status",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
zap.Error(Error.Wrap(err)),
)
continue
}
if freeze != nil {
chore.log.Debug("Ignoring invoice; account already frozen",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
)
continue
}
if warning == nil {
err = chore.freezeService.WarnUser(ctx, userID)
if err != nil {
chore.log.Error("Could not add warning event",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
zap.Error(Error.Wrap(err)),
)
continue
}
chore.log.Debug("user warned",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
)
warnedMap[userID] = struct{}{}
continue
}
if chore.nowFn().Sub(warning.CreatedAt) > chore.config.GracePeriod {
err = chore.freezeService.FreezeUser(ctx, userID)
if err != nil {
chore.log.Error("Could not freeze account",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
zap.Error(Error.Wrap(err)),
)
continue
}
chore.log.Debug("user frozen",
zap.String("invoiceID", invoice.ID),
zap.String("customerID", invoice.CustomerID),
zap.Any("userID", userID),
)
frozenMap[userID] = struct{}{}
}
}
chore.log.Debug("chore 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 bypassed", len(bypassedMap)),
)
return nil
})
}
// TestSetNow sets nowFn on chore for testing.
func (chore *Chore) TestSetNow(f func() time.Time) {
chore.nowFn = f
}
// Close closes the chore.
func (chore *Chore) Close() error {
chore.Loop.Close()
return nil
}