fd1a94a84d
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
196 lines
5.8 KiB
Go
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
|
|
}
|