storj/satellite/payments/accountfreeze/chore.go

196 lines
5.8 KiB
Go
Raw Normal View History

// 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
}