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:
parent
c79d1b0d2f
commit
bd4d57c604
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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) {}
|
||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user