satellite/payments: Exclude users who pay via storjscan from autofreeze

Add a configuration (default true) to exclude users who have made
storjscan payments from being auto-warned/frozen for an unpaid invoice.
This will allow us to reach out to these users and handle warning/freezing
manually. Auto account freeze still handles CC-only users.

Fixes https://github.com/storj/storj/issues/6027

Change-Id: I0c862785dad1c8febfa11100c0d30e621ce3ae9b
This commit is contained in:
Moby von Briesen 2023-07-07 13:31:53 -04:00 committed by Storj Robot
parent c79d1b0d2f
commit bd4d57c604
5 changed files with 162 additions and 8 deletions

View File

@ -84,6 +84,7 @@ const (
eventAccountUnwarned = "Account Unwarned"
eventAccountFreezeWarning = "Account Freeze Warning"
eventUnpaidLargeInvoice = "Large Invoice Unpaid"
eventUnpaidStorjscanInvoice = "Storjscan Invoice Unpaid"
eventExpiredCreditNeedsRemoval = "Expired Credit Needs Removal"
eventExpiredCreditRemoved = "Expired Credit Removed"
eventProjectInvitationAccepted = "Project Invitation Accepted"
@ -122,6 +123,9 @@ type FreezeTracker interface {
// TrackLargeUnpaidInvoice sends an event to Segment indicating that a user has not paid a large invoice.
TrackLargeUnpaidInvoice(invID string, userID uuid.UUID, email string)
// TrackStorjscanUnpaidInvoice sends an event to Segment indicating that a user has not paid an invoice, but has storjscan transaction history.
TrackStorjscanUnpaidInvoice(invID string, userID uuid.UUID, email string)
}
// Service for sending analytics.
@ -418,6 +422,23 @@ func (service *Service) TrackLargeUnpaidInvoice(invID string, userID uuid.UUID,
})
}
// TrackStorjscanUnpaidInvoice sends an event to Segment indicating that a user has not paid an invoice, but has storjscan transaction history.
func (service *Service) TrackStorjscanUnpaidInvoice(invID string, userID uuid.UUID, email string) {
if !service.config.Enabled {
return
}
props := segment.NewProperties()
props.Set("email", email)
props.Set("invoice", invID)
service.enqueueMessage(segment.Track{
UserId: userID.String(),
Event: service.satelliteName + " " + eventUnpaidStorjscanInvoice,
Properties: props,
})
}
// TrackAccessGrantCreated sends an "Access Grant Created" event to Segment.
func (service *Service) TrackAccessGrantCreated(userID uuid.UUID, email string) {
if !service.config.Enabled {

View File

@ -534,6 +534,8 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.DB.StripeCoinPayments(),
peer.Payments.Accounts,
peer.DB.Console().Users(),
peer.DB.Wallets(),
peer.DB.StorjscanPayments(),
console.NewAccountFreezeService(db.Console().AccountFreezeEvents(), db.Console().Users(), db.Console().Projects(), peer.Analytics.Service),
peer.Analytics.Service,
config.AccountFreeze,

View File

@ -16,6 +16,8 @@ import (
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/billing"
"storj.io/storj/satellite/payments/storjscan"
"storj.io/storj/satellite/payments/stripe"
)
@ -27,10 +29,11 @@ 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:"10000"`
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:"10000"`
ExcludeStorjscan bool `help:"whether to exclude storjscan-paying users from automatic warn/freeze" default:"true"`
}
// Chore is a chore that checks for unpaid invoices and potentially freezes corresponding accounts.
@ -39,6 +42,8 @@ type Chore struct {
freezeService *console.AccountFreezeService
analytics *analytics.Service
usersDB console.Users
walletsDB storjscan.WalletsDB
paymentsDB storjscan.PaymentsDB
payments payments.Accounts
accounts stripe.DB
config Config
@ -47,12 +52,14 @@ type Chore struct {
}
// NewChore is a constructor for Chore.
func NewChore(log *zap.Logger, accounts stripe.DB, payments payments.Accounts, usersDB console.Users, freezeService *console.AccountFreezeService, analytics *analytics.Service, config Config) *Chore {
func NewChore(log *zap.Logger, accounts stripe.DB, payments payments.Accounts, usersDB console.Users, walletsDB storjscan.WalletsDB, paymentsDB storjscan.PaymentsDB, freezeService *console.AccountFreezeService, analytics *analytics.Service, config Config) *Chore {
return &Chore{
log: log,
freezeService: freezeService,
analytics: analytics,
usersDB: usersDB,
walletsDB: walletsDB,
paymentsDB: paymentsDB,
accounts: accounts,
config: config,
payments: payments,
@ -76,7 +83,8 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
userMap := make(map[uuid.UUID]struct{})
frozenMap := make(map[uuid.UUID]struct{})
warnedMap := make(map[uuid.UUID]struct{})
bypassedMap := make(map[uuid.UUID]struct{})
bypassedLargeMap := make(map[uuid.UUID]struct{})
bypassedTokenMap := make(map[uuid.UUID]struct{})
checkInvPaid := func(invID string) (bool, error) {
inv, err := chore.payments.Invoices().Get(ctx, invID)
@ -123,12 +131,40 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
}
if invoice.Amount > chore.config.PriceThreshold {
bypassedMap[userID] = struct{}{}
if _, ok := bypassedLargeMap[userID]; ok {
continue
}
bypassedLargeMap[userID] = struct{}{}
debugLog("Ignoring invoice; amount exceeds threshold")
chore.analytics.TrackLargeUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
if chore.config.ExcludeStorjscan {
if _, ok := bypassedTokenMap[userID]; ok {
continue
}
wallet, err := chore.walletsDB.GetWallet(ctx, user.ID)
if err != nil && !errs.Is(err, billing.ErrNoWallet) {
errorLog("Could not get wallets for user", err)
continue
}
// if there is no error, the user has a wallet and we can check for transactions
if err == nil {
cachedPayments, err := chore.paymentsDB.ListWallet(ctx, wallet, 1, 0)
if err != nil && !errs.Is(err, billing.ErrNoTransactions) {
errorLog("Could not get payments for user", err)
continue
}
if len(cachedPayments) > 0 {
bypassedTokenMap[userID] = struct{}{}
debugLog("Ignoring invoice; TX exists in storjscan")
chore.analytics.TrackStorjscanUnpaidInvoice(invoice.ID, userID, user.Email)
continue
}
}
}
freeze, warning, err := chore.freezeService.GetAll(ctx, userID)
if err != nil {
errorLog("Could not get freeze status", err)
@ -186,7 +222,8 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
zap.Int("user total", len(userMap)),
zap.Int("total warned", len(warnedMap)),
zap.Int("total frozen", len(frozenMap)),
zap.Int("total bypassed", len(bypassedMap)),
zap.Int("total bypassed due to size of invoice", len(bypassedLargeMap)),
zap.Int("total bypassed due to storjscan payments", len(bypassedTokenMap)),
)
return nil

View File

@ -11,11 +11,17 @@ import (
"github.com/stripe/stripe-go/v72"
"go.uber.org/zap"
"storj.io/common/currency"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/common/uuid"
"storj.io/storj/private/blockchain"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/storjscan"
"storj.io/storj/satellite/payments/storjscan/blockchaintest"
stripe1 "storj.io/storj/satellite/payments/stripe"
)
@ -166,6 +172,89 @@ func TestAutoFreezeChore(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, freeze)
})
t.Run("Storjscan exceptions", func(t *testing.T) {
// AnalyticsMock tests that events are sent once.
service.TestChangeFreezeTracker(newFreezeTrackerMock(t))
// reset chore clock
chore.TestSetNow(time.Now)
storjscanUser, err := sat.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "storjscanuser@mail.test",
}, 1)
require.NoError(t, err)
// create a wallet and transaction for the new user in storjscan
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
require.NoError(t, sat.DB.Wallets().Add(ctx, storjscanUser.ID, address))
cachedPayments := []storjscan.CachedPayment{
{
From: blockchaintest.NewAddress(),
To: address,
TokenValue: currency.AmountFromBaseUnits(1000, currency.StorjToken),
USDValue: currency.AmountFromBaseUnits(testrand.Int63n(1000), currency.USDollarsMicro),
BlockHash: blockchaintest.NewHash(),
Transaction: blockchaintest.NewHash(),
Status: payments.PaymentStatusConfirmed,
Timestamp: time.Now(),
},
}
require.NoError(t, sat.DB.StorjscanPayments().InsertBatch(ctx, cachedPayments))
storjscanCus, err := customerDB.GetCustomerID(ctx, storjscanUser.ID)
require.NoError(t, err)
item, err := stripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
Params: stripe.Params{Context: ctx},
Amount: &amount,
Currency: &curr,
Customer: &storjscanCus,
})
require.NoError(t, err)
items := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 1)
items = append(items, &stripe.InvoiceUpcomingInvoiceItemParams{
InvoiceItem: &item.ID,
Amount: &amount,
Currency: &curr,
})
inv, err := stripeClient.Invoices().New(&stripe.InvoiceParams{
Params: stripe.Params{Context: ctx},
Customer: &storjscanCus,
InvoiceItems: items,
})
require.NoError(t, err)
paymentMethod := stripe1.MockInvoicesPayFailure
inv, err = stripeClient.Invoices().Pay(inv.ID, &stripe.InvoicePayParams{
Params: stripe.Params{Context: ctx},
PaymentMethod: &paymentMethod,
})
require.Error(t, err)
require.Equal(t, stripe.InvoiceStatusOpen, inv.Status)
failed, err := invoicesDB.ListFailed(ctx)
require.NoError(t, err)
require.Equal(t, 2, len(failed))
invFound := false
for _, failedInv := range failed {
if failedInv.ID == inv.ID {
invFound = true
break
}
}
require.True(t, invFound)
chore.Loop.TriggerWait()
// user should not be warned or frozen due to storjscan payments
freeze, warning, err := service.GetAll(ctx, storjscanUser.ID)
require.NoError(t, err)
require.Nil(t, warning)
require.Nil(t, freeze)
})
})
}
@ -211,3 +300,5 @@ func (mock *freezeTrackerMock) TrackAccountFreezeWarning(_ uuid.UUID, email stri
}
func (mock *freezeTrackerMock) TrackLargeUnpaidInvoice(_ string, _ uuid.UUID, _ string) {}
func (mock *freezeTrackerMock) TrackStorjscanUnpaidInvoice(_ string, _ uuid.UUID, _ string) {}

View File

@ -1,6 +1,9 @@
# whether to run this chore.
# account-freeze.enabled: false
# whether to exclude storjscan-paying users from automatic warn/freeze
# account-freeze.exclude-storjscan: true
# How long to wait between a warning event and freezing an account.
# account-freeze.grace-period: 360h0m0s