Skip to content

Commit

Permalink
satellite/payments: Exclude users who pay via storjscan from autofreeze
Browse files Browse the repository at this point in the history
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 #6027

Change-Id: I0c862785dad1c8febfa11100c0d30e621ce3ae9b
  • Loading branch information
mobyvb authored and Storj Robot committed Jul 10, 2023
1 parent c79d1b0 commit bd4d57c
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 8 deletions.
21 changes: 21 additions & 0 deletions satellite/analytics/service.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions satellite/core.go
Expand Up @@ -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,
Expand Down
53 changes: 45 additions & 8 deletions satellite/payments/accountfreeze/chore.go
Expand Up @@ -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"
)

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions satellite/payments/accountfreeze/chore_test.go
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
})
})
}

Expand Down Expand Up @@ -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) {}
3 changes: 3 additions & 0 deletions scripts/testdata/satellite-config.yaml.lock
@@ -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

Expand Down

0 comments on commit bd4d57c

Please sign in to comment.