satellite/payments: Remove expired package credits
During billing, before invoice creation, check if users are part of a package plan. If so, and if the package plan is expired, remove unused credit from the user's balance. If the user has credit in addition to the package credit, send an analytics event to notify someone to handle the credit removal manually. Change-Id: Iad71d791f67c9733f9d9e42f962c64b2780264cc
This commit is contained in:
parent
250704493d
commit
09ec5f107d
@ -14,6 +14,7 @@ import (
|
|||||||
"storj.io/common/uuid"
|
"storj.io/common/uuid"
|
||||||
"storj.io/private/process"
|
"storj.io/private/process"
|
||||||
"storj.io/storj/satellite"
|
"storj.io/storj/satellite"
|
||||||
|
"storj.io/storj/satellite/analytics"
|
||||||
"storj.io/storj/satellite/payments/stripe"
|
"storj.io/storj/satellite/payments/stripe"
|
||||||
"storj.io/storj/satellite/satellitedb"
|
"storj.io/storj/satellite/satellitedb"
|
||||||
)
|
)
|
||||||
@ -76,7 +77,9 @@ func setupPayments(log *zap.Logger, db satellite.DB) (*stripe.Service, error) {
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
analytics.NewService(log.Named("analytics:service"), runCfg.Analytics, runCfg.Console.SatelliteName),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseYearMonth parses year and month from the provided string and returns a corresponding time.Time for the first day
|
// parseYearMonth parses year and month from the provided string and returns a corresponding time.Time for the first day
|
||||||
|
@ -53,6 +53,10 @@ type Admin struct {
|
|||||||
Service *checker.Service
|
Service *checker.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Analytics struct {
|
||||||
|
Service *analytics.Service
|
||||||
|
}
|
||||||
|
|
||||||
Payments struct {
|
Payments struct {
|
||||||
Accounts payments.Accounts
|
Accounts payments.Accounts
|
||||||
Service *stripe.Service
|
Service *stripe.Service
|
||||||
@ -135,6 +139,16 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{ // setup analytics
|
||||||
|
peer.Analytics.Service = analytics.NewService(peer.Log.Named("analytics:service"), config.Analytics, config.Console.SatelliteName)
|
||||||
|
|
||||||
|
peer.Services.Add(lifecycle.Item{
|
||||||
|
Name: "analytics:service",
|
||||||
|
Run: peer.Analytics.Service.Run,
|
||||||
|
Close: peer.Analytics.Service.Close,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
{ // setup payments
|
{ // setup payments
|
||||||
pc := config.Payments
|
pc := config.Payments
|
||||||
|
|
||||||
@ -163,6 +177,13 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
|
|||||||
return nil, errs.Combine(err, peer.Close())
|
return nil, errs.Combine(err, peer.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peer.FreezeAccounts.Service = console.NewAccountFreezeService(
|
||||||
|
db.Console().AccountFreezeEvents(),
|
||||||
|
db.Console().Users(),
|
||||||
|
db.Console().Projects(),
|
||||||
|
peer.Analytics.Service,
|
||||||
|
)
|
||||||
|
|
||||||
peer.Payments.Service, err = stripe.NewService(
|
peer.Payments.Service, err = stripe.NewService(
|
||||||
peer.Log.Named("payments.stripe:service"),
|
peer.Log.Named("payments.stripe:service"),
|
||||||
stripeClient,
|
stripeClient,
|
||||||
@ -176,7 +197,9 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
peer.Analytics.Service,
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Combine(err, peer.Close())
|
return nil, errs.Combine(err, peer.Close())
|
||||||
@ -184,12 +207,6 @@ func NewAdmin(log *zap.Logger, full *identity.FullIdentity, db DB, metabaseDB *m
|
|||||||
|
|
||||||
peer.Payments.Stripe = stripeClient
|
peer.Payments.Stripe = stripeClient
|
||||||
peer.Payments.Accounts = peer.Payments.Service.Accounts()
|
peer.Payments.Accounts = peer.Payments.Service.Accounts()
|
||||||
peer.FreezeAccounts.Service = console.NewAccountFreezeService(
|
|
||||||
db.Console().AccountFreezeEvents(),
|
|
||||||
db.Console().Users(),
|
|
||||||
db.Console().Projects(),
|
|
||||||
analytics.NewService(peer.Log.Named("analytics:service"), config.Analytics, config.Console.SatelliteName),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{ // setup admin endpoint
|
{ // setup admin endpoint
|
||||||
|
@ -84,6 +84,8 @@ const (
|
|||||||
eventAccountUnwarned = "Account Unwarned"
|
eventAccountUnwarned = "Account Unwarned"
|
||||||
eventAccountFreezeWarning = "Account Freeze Warning"
|
eventAccountFreezeWarning = "Account Freeze Warning"
|
||||||
eventUnpaidLargeInvoice = "Large Invoice Unpaid"
|
eventUnpaidLargeInvoice = "Large Invoice Unpaid"
|
||||||
|
eventExpiredCreditNeedsRemoval = "Expired Credit Needs Removal"
|
||||||
|
eventExpiredCreditRemoved = "Expired Credit Removed"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -636,3 +638,37 @@ func (service *Service) TrackProjectMemberDeletion(userID uuid.UUID, email strin
|
|||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrackExpiredCreditNeedsRemoval sends an "Expired Credit Needs Removal" event to Segment.
|
||||||
|
func (service *Service) TrackExpiredCreditNeedsRemoval(userID uuid.UUID, customerID, packagePlan string) {
|
||||||
|
if !service.config.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props := segment.NewProperties()
|
||||||
|
props.Set("customer ID", customerID)
|
||||||
|
props.Set("package plan", packagePlan)
|
||||||
|
|
||||||
|
service.enqueueMessage(segment.Track{
|
||||||
|
UserId: userID.String(),
|
||||||
|
Event: service.satelliteName + " " + eventExpiredCreditNeedsRemoval,
|
||||||
|
Properties: props,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackExpiredCreditRemoved sends an "Expired Credit Removed" event to Segment.
|
||||||
|
func (service *Service) TrackExpiredCreditRemoved(userID uuid.UUID, customerID, packagePlan string) {
|
||||||
|
if !service.config.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props := segment.NewProperties()
|
||||||
|
props.Set("customer ID", customerID)
|
||||||
|
props.Set("package plan", packagePlan)
|
||||||
|
|
||||||
|
service.enqueueMessage(segment.Track{
|
||||||
|
UserId: userID.String(),
|
||||||
|
Event: service.satelliteName + " " + eventExpiredCreditRemoved,
|
||||||
|
Properties: props,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -540,7 +540,9 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
peer.Analytics.Service,
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Combine(err, peer.Close())
|
return nil, errs.Combine(err, peer.Close())
|
||||||
|
@ -95,7 +95,9 @@ func TestGraphqlMutation(t *testing.T) {
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
service, err := console.NewService(
|
service, err := console.NewService(
|
||||||
|
@ -79,7 +79,9 @@ func TestGraphqlQuery(t *testing.T) {
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
service, err := console.NewService(
|
service, err := console.NewService(
|
||||||
|
@ -69,6 +69,10 @@ type Core struct {
|
|||||||
Service *version_checker.Service
|
Service *version_checker.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Analytics struct {
|
||||||
|
Service *analytics.Service
|
||||||
|
}
|
||||||
|
|
||||||
Mail struct {
|
Mail struct {
|
||||||
Service *mailservice.Service
|
Service *mailservice.Service
|
||||||
EmailReminders *emailreminders.Chore
|
EmailReminders *emailreminders.Chore
|
||||||
@ -418,6 +422,16 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{ // setup analytics service
|
||||||
|
peer.Analytics.Service = analytics.NewService(peer.Log.Named("analytics:service"), config.Analytics, config.Console.SatelliteName)
|
||||||
|
|
||||||
|
peer.Services.Add(lifecycle.Item{
|
||||||
|
Name: "analytics:service",
|
||||||
|
Run: peer.Analytics.Service.Run,
|
||||||
|
Close: peer.Analytics.Service.Close,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: remove in future, should be in API
|
// TODO: remove in future, should be in API
|
||||||
{ // setup payments
|
{ // setup payments
|
||||||
pc := config.Payments
|
pc := config.Payments
|
||||||
@ -460,7 +474,9 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
peer.Analytics.Service,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Combine(err, peer.Close())
|
return nil, errs.Combine(err, peer.Close())
|
||||||
}
|
}
|
||||||
@ -513,14 +529,13 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
|
|||||||
|
|
||||||
{ // setup account freeze
|
{ // setup account freeze
|
||||||
if config.AccountFreeze.Enabled {
|
if config.AccountFreeze.Enabled {
|
||||||
analyticService := analytics.NewService(peer.Log.Named("analytics:service"), config.Analytics, config.Console.SatelliteName)
|
|
||||||
peer.Payments.AccountFreeze = accountfreeze.NewChore(
|
peer.Payments.AccountFreeze = accountfreeze.NewChore(
|
||||||
peer.Log.Named("payments.accountfreeze:chore"),
|
peer.Log.Named("payments.accountfreeze:chore"),
|
||||||
peer.DB.StripeCoinPayments(),
|
peer.DB.StripeCoinPayments(),
|
||||||
peer.Payments.Accounts,
|
peer.Payments.Accounts,
|
||||||
peer.DB.Console().Users(),
|
peer.DB.Console().Users(),
|
||||||
console.NewAccountFreezeService(db.Console().AccountFreezeEvents(), db.Console().Users(), db.Console().Projects(), analyticService),
|
console.NewAccountFreezeService(db.Console().AccountFreezeEvents(), db.Console().Users(), db.Console().Projects(), peer.Analytics.Service),
|
||||||
analyticService,
|
peer.Analytics.Service,
|
||||||
config.AccountFreeze,
|
config.AccountFreeze,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -73,7 +73,9 @@ func TestSignupCouponCodes(t *testing.T) {
|
|||||||
prices,
|
prices,
|
||||||
priceOverrides,
|
priceOverrides,
|
||||||
pc.PackagePlans.Packages,
|
pc.PackagePlans.Packages,
|
||||||
pc.BonusRate)
|
pc.BonusRate,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
service, err := console.NewService(
|
service, err := console.NewService(
|
||||||
|
@ -58,12 +58,12 @@ func TestBalances(t *testing.T) {
|
|||||||
list, err := balances.ListTransactions(ctx, userID)
|
list, err := balances.ListTransactions(ctx, userID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, list, 3)
|
require.Len(t, list, 3)
|
||||||
require.Equal(t, tx1Amount, list[0].Amount)
|
require.Equal(t, tx3Amount, list[0].Amount)
|
||||||
require.Equal(t, tx1Desc, list[0].Description)
|
require.Equal(t, tx3Desc, list[0].Description)
|
||||||
require.Equal(t, tx2Amount, list[1].Amount)
|
require.Equal(t, tx2Amount, list[1].Amount)
|
||||||
require.Equal(t, tx2Desc, list[1].Description)
|
require.Equal(t, tx2Desc, list[1].Description)
|
||||||
require.Equal(t, tx3Amount, list[2].Amount)
|
require.Equal(t, tx1Amount, list[2].Amount)
|
||||||
require.Equal(t, tx3Desc, list[2].Description)
|
require.Equal(t, tx1Desc, list[2].Description)
|
||||||
|
|
||||||
b, err = balances.ApplyCredit(ctx, userID, tx2Amount, stripe.MockCBTXsNewFailure)
|
b, err = balances.ApplyCredit(ctx, userID, tx2Amount, stripe.MockCBTXsNewFailure)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
@ -25,6 +25,7 @@ import (
|
|||||||
"storj.io/common/sync2"
|
"storj.io/common/sync2"
|
||||||
"storj.io/common/uuid"
|
"storj.io/common/uuid"
|
||||||
"storj.io/storj/satellite/accounting"
|
"storj.io/storj/satellite/accounting"
|
||||||
|
"storj.io/storj/satellite/analytics"
|
||||||
"storj.io/storj/satellite/console"
|
"storj.io/storj/satellite/console"
|
||||||
"storj.io/storj/satellite/payments"
|
"storj.io/storj/satellite/payments"
|
||||||
"storj.io/storj/satellite/payments/billing"
|
"storj.io/storj/satellite/payments/billing"
|
||||||
@ -50,6 +51,7 @@ type Config struct {
|
|||||||
ListingLimit int `help:"sets the maximum amount of items before we start paging on requests" default:"100" hidden:"true"`
|
ListingLimit int `help:"sets the maximum amount of items before we start paging on requests" default:"100" hidden:"true"`
|
||||||
SkipEmptyInvoices bool `help:"if set, skips the creation of empty invoices for customers with zero usage for the billing period" default:"true"`
|
SkipEmptyInvoices bool `help:"if set, skips the creation of empty invoices for customers with zero usage for the billing period" default:"true"`
|
||||||
MaxParallelCalls int `help:"the maximum number of concurrent Stripe API calls in invoicing methods" default:"10"`
|
MaxParallelCalls int `help:"the maximum number of concurrent Stripe API calls in invoicing methods" default:"10"`
|
||||||
|
RemoveExpiredCredit bool `help:"whether to remove expired package credit or not" default:"true"`
|
||||||
Retries RetryConfig
|
Retries RetryConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +70,8 @@ type Service struct {
|
|||||||
usageDB accounting.ProjectAccounting
|
usageDB accounting.ProjectAccounting
|
||||||
stripeClient Client
|
stripeClient Client
|
||||||
|
|
||||||
|
analytics *analytics.Service
|
||||||
|
|
||||||
usagePrices payments.ProjectUsagePriceModel
|
usagePrices payments.ProjectUsagePriceModel
|
||||||
usagePriceOverrides map[string]payments.ProjectUsagePriceModel
|
usagePriceOverrides map[string]payments.ProjectUsagePriceModel
|
||||||
packagePlans map[string]payments.PackagePlan
|
packagePlans map[string]payments.PackagePlan
|
||||||
@ -80,14 +84,15 @@ type Service struct {
|
|||||||
// Stripe Extended Features
|
// Stripe Extended Features
|
||||||
AutoAdvance bool
|
AutoAdvance bool
|
||||||
|
|
||||||
listingLimit int
|
listingLimit int
|
||||||
skipEmptyInvoices bool
|
skipEmptyInvoices bool
|
||||||
maxParallelCalls int
|
maxParallelCalls int
|
||||||
nowFn func() time.Time
|
removeExpiredCredit bool
|
||||||
|
nowFn func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a Service instance.
|
// NewService creates a Service instance.
|
||||||
func NewService(log *zap.Logger, stripeClient Client, config Config, db DB, walletsDB storjscan.WalletsDB, billingDB billing.TransactionsDB, projectsDB console.Projects, usersDB console.Users, usageDB accounting.ProjectAccounting, usagePrices payments.ProjectUsagePriceModel, usagePriceOverrides map[string]payments.ProjectUsagePriceModel, packagePlans map[string]payments.PackagePlan, bonusRate int64) (*Service, error) {
|
func NewService(log *zap.Logger, stripeClient Client, config Config, db DB, walletsDB storjscan.WalletsDB, billingDB billing.TransactionsDB, projectsDB console.Projects, usersDB console.Users, usageDB accounting.ProjectAccounting, usagePrices payments.ProjectUsagePriceModel, usagePriceOverrides map[string]payments.ProjectUsagePriceModel, packagePlans map[string]payments.PackagePlan, bonusRate int64, analyticsService *analytics.Service) (*Service, error) {
|
||||||
var partners []string
|
var partners []string
|
||||||
for partner := range usagePriceOverrides {
|
for partner := range usagePriceOverrides {
|
||||||
partners = append(partners, partner)
|
partners = append(partners, partner)
|
||||||
@ -102,6 +107,7 @@ func NewService(log *zap.Logger, stripeClient Client, config Config, db DB, wall
|
|||||||
usersDB: usersDB,
|
usersDB: usersDB,
|
||||||
usageDB: usageDB,
|
usageDB: usageDB,
|
||||||
stripeClient: stripeClient,
|
stripeClient: stripeClient,
|
||||||
|
analytics: analyticsService,
|
||||||
usagePrices: usagePrices,
|
usagePrices: usagePrices,
|
||||||
usagePriceOverrides: usagePriceOverrides,
|
usagePriceOverrides: usagePriceOverrides,
|
||||||
packagePlans: packagePlans,
|
packagePlans: packagePlans,
|
||||||
@ -112,6 +118,7 @@ func NewService(log *zap.Logger, stripeClient Client, config Config, db DB, wall
|
|||||||
listingLimit: config.ListingLimit,
|
listingLimit: config.ListingLimit,
|
||||||
skipEmptyInvoices: config.SkipEmptyInvoices,
|
skipEmptyInvoices: config.SkipEmptyInvoices,
|
||||||
maxParallelCalls: config.MaxParallelCalls,
|
maxParallelCalls: config.MaxParallelCalls,
|
||||||
|
removeExpiredCredit: config.RemoveExpiredCredit,
|
||||||
nowFn: time.Now,
|
nowFn: time.Now,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -573,6 +580,75 @@ func (service *Service) InvoiceItemsFromProjectUsage(projName string, partnerUsa
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveExpiredPackageCredit removes a user's package plan credit, or sends an analytics event, if it has expired.
|
||||||
|
// If the user has never received credit from anything other than the package, and it is expired, the remaining package
|
||||||
|
// credit is removed. If the user has received credit from another source, we send an analytics event instead of removing
|
||||||
|
// the remaining credit so someone can remove it manually. `sentEvent` indicates whether this analytics event was sent.
|
||||||
|
func (service *Service) RemoveExpiredPackageCredit(ctx context.Context, customer Customer) (sentEvent bool, err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
// TODO: store the package expiration somewhere
|
||||||
|
if customer.PackagePlan == nil || customer.PackagePurchasedAt == nil ||
|
||||||
|
customer.PackagePurchasedAt.After(service.nowFn().AddDate(-1, -1, 0)) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
list := service.stripeClient.CustomerBalanceTransactions().List(&stripe.CustomerBalanceTransactionListParams{
|
||||||
|
Customer: stripe.String(customer.ID),
|
||||||
|
})
|
||||||
|
|
||||||
|
var balance int64
|
||||||
|
var gotBalance, foundOtherCredit bool
|
||||||
|
var tx *stripe.CustomerBalanceTransaction
|
||||||
|
|
||||||
|
for list.Next() {
|
||||||
|
tx = list.CustomerBalanceTransaction()
|
||||||
|
if !gotBalance {
|
||||||
|
// Stripe returns list ordered by most recent, so ending balance of the first item is current balance.
|
||||||
|
balance = tx.EndingBalance
|
||||||
|
gotBalance = true
|
||||||
|
// if user doesn't have credit, we're done.
|
||||||
|
if balance >= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// negative amount means credit
|
||||||
|
if tx.Amount < 0 {
|
||||||
|
if tx.Description != *customer.PackagePlan {
|
||||||
|
foundOtherCredit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send analytics event to notify someone to handle removing credit if credit other than package exists.
|
||||||
|
if foundOtherCredit {
|
||||||
|
if service.analytics != nil {
|
||||||
|
service.analytics.TrackExpiredCreditNeedsRemoval(customer.UserID, customer.ID, *customer.PackagePlan)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no other credit found, we can set the balance to zero.
|
||||||
|
if balance < 0 {
|
||||||
|
_, err = service.stripeClient.CustomerBalanceTransactions().New(&stripe.CustomerBalanceTransactionParams{
|
||||||
|
Customer: stripe.String(customer.ID),
|
||||||
|
Amount: stripe.Int64(-balance),
|
||||||
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||||
|
Description: stripe.String(fmt.Sprintf("%s expired", *customer.PackagePlan)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
if service.analytics != nil {
|
||||||
|
service.analytics.TrackExpiredCreditRemoved(customer.UserID, customer.ID, *customer.PackagePlan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = service.Accounts().UpdatePackage(ctx, customer.UserID, nil, nil)
|
||||||
|
|
||||||
|
return false, Error.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyFreeTierCoupons iterates through all customers in Stripe. For each customer,
|
// ApplyFreeTierCoupons iterates through all customers in Stripe. For each customer,
|
||||||
// if that customer does not currently have a Stripe coupon, the free tier Stripe coupon
|
// if that customer does not currently have a Stripe coupon, the free tier Stripe coupon
|
||||||
// is applied.
|
// is applied.
|
||||||
@ -661,7 +737,7 @@ func (service *Service) applyFreeTierCoupon(ctx context.Context, cusID string) (
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInvoices lists through all customers and creates invoices.
|
// CreateInvoices lists through all customers, removes expired credit if applicable, and creates invoices.
|
||||||
func (service *Service) CreateInvoices(ctx context.Context, period time.Time) (err error) {
|
func (service *Service) CreateInvoices(ctx context.Context, period time.Time) (err error) {
|
||||||
defer mon.Task()(&ctx)(&err)
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
@ -683,6 +759,16 @@ func (service *Service) CreateInvoices(ctx context.Context, period time.Time) (e
|
|||||||
return Error.Wrap(err)
|
return Error.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if service.removeExpiredCredit {
|
||||||
|
for _, c := range cusPage.Customers {
|
||||||
|
if c.PackagePlan != nil {
|
||||||
|
if _, err := service.RemoveExpiredPackageCredit(ctx, c); err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scheduled, draft, err := service.createInvoices(ctx, cusPage.Customers, start)
|
scheduled, draft, err := service.createInvoices(ctx, cusPage.Customers, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Error.Wrap(err)
|
return Error.Wrap(err)
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v72"
|
"github.com/stripe/stripe-go/v72"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@ -967,3 +968,136 @@ func TestPayInvoicesSkipDue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoveExpiredPackageCredit(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 4,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
satellite := planet.Satellites[0]
|
||||||
|
p := satellite.API.Payments
|
||||||
|
u0 := planet.Uplinks[0].Projects[0].Owner.ID
|
||||||
|
u1 := planet.Uplinks[1].Projects[0].Owner.ID
|
||||||
|
u2 := planet.Uplinks[2].Projects[0].Owner.ID
|
||||||
|
u3 := planet.Uplinks[3].Projects[0].Owner.ID
|
||||||
|
|
||||||
|
credit := int64(1000)
|
||||||
|
pkgDesc := "test package plan"
|
||||||
|
now := time.Now()
|
||||||
|
expiredPurchase := now.AddDate(-1, -1, 0)
|
||||||
|
|
||||||
|
removeExpiredCredit := func(u uuid.UUID, expectAlert bool, purchaseTime *time.Time) {
|
||||||
|
require.NoError(t, p.Accounts.UpdatePackage(ctx, u, &pkgDesc, purchaseTime))
|
||||||
|
cPage, err := satellite.API.DB.StripeCoinPayments().Customers().List(ctx, uuid.UUID{}, 10, time.Now().Add(1*time.Hour))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var c stripe1.Customer
|
||||||
|
for _, cus := range cPage.Customers {
|
||||||
|
if cus.UserID == u {
|
||||||
|
c = cus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alertSent, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{
|
||||||
|
ID: c.ID,
|
||||||
|
UserID: c.UserID,
|
||||||
|
PackagePlan: c.PackagePlan,
|
||||||
|
PackagePurchasedAt: c.PackagePurchasedAt,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
if expectAlert {
|
||||||
|
require.True(t, alertSent)
|
||||||
|
} else {
|
||||||
|
require.False(t, alertSent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCreditAndPackage := func(u uuid.UUID, expectedBalance int64, expectNilPackage bool) {
|
||||||
|
b, err := p.Accounts.Balances().Get(ctx, u)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(expectedBalance), b.Credits)
|
||||||
|
|
||||||
|
dbPkgInfo, dbPurchaseTime, err := p.StripeService.Accounts().GetPackageInfo(ctx, u)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if expectNilPackage {
|
||||||
|
require.Nil(t, dbPkgInfo)
|
||||||
|
require.Nil(t, dbPurchaseTime)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, dbPkgInfo)
|
||||||
|
require.NotNil(t, dbPurchaseTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("nil package plan returns safely", func(t *testing.T) {
|
||||||
|
_, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{
|
||||||
|
ID: "test-customer-ID",
|
||||||
|
UserID: testrand.UUID(),
|
||||||
|
PackagePlan: nil,
|
||||||
|
PackagePurchasedAt: &now,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil package purchase time returns safely", func(t *testing.T) {
|
||||||
|
_, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{
|
||||||
|
ID: "test-customer-ID",
|
||||||
|
UserID: testrand.UUID(),
|
||||||
|
PackagePlan: new(string),
|
||||||
|
PackagePurchasedAt: nil,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("package not expired retains credit", func(t *testing.T) {
|
||||||
|
b, err := p.Accounts.Balances().ApplyCredit(ctx, u3, credit, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(credit), b.Credits)
|
||||||
|
|
||||||
|
removeExpiredCredit(u3, false, &now)
|
||||||
|
checkCreditAndPackage(u3, credit, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("used all credit", func(t *testing.T) {
|
||||||
|
b, err := p.Accounts.Balances().ApplyCredit(ctx, u0, credit, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(credit), b.Credits)
|
||||||
|
|
||||||
|
// remove credit as if they used it all
|
||||||
|
b, err = p.Accounts.Balances().ApplyCredit(ctx, u0, -credit, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(0), b.Credits)
|
||||||
|
|
||||||
|
removeExpiredCredit(u0, false, &expiredPurchase)
|
||||||
|
checkCreditAndPackage(u0, 0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("has remaining credit but no credit source other than package", func(t *testing.T) {
|
||||||
|
b, err := p.Accounts.Balances().ApplyCredit(ctx, u1, credit, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(credit), b.Credits)
|
||||||
|
|
||||||
|
// remove some credit, but not all, as if it were used
|
||||||
|
toRemove := credit / 2
|
||||||
|
remaining := credit - toRemove
|
||||||
|
b, err = p.Accounts.Balances().ApplyCredit(ctx, u1, -toRemove, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(remaining), b.Credits)
|
||||||
|
|
||||||
|
removeExpiredCredit(u1, false, &expiredPurchase)
|
||||||
|
checkCreditAndPackage(u1, 0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("has additional credit source", func(t *testing.T) {
|
||||||
|
b, err := p.Accounts.Balances().ApplyCredit(ctx, u2, credit, pkgDesc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(credit), b.Credits)
|
||||||
|
|
||||||
|
// give additional credit
|
||||||
|
additional := int64(2000)
|
||||||
|
b, err = p.Accounts.Balances().ApplyCredit(ctx, u2, additional, "additional credit")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, decimal.NewFromInt(credit+additional), b.Credits)
|
||||||
|
|
||||||
|
removeExpiredCredit(u2, true, &expiredPurchase)
|
||||||
|
checkCreditAndPackage(u2, credit+additional, false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -780,7 +780,8 @@ func (m *mockCustomerBalanceTransactions) List(listParams *stripe.CustomerBalanc
|
|||||||
ret := make([]interface{}, len(txs))
|
ret := make([]interface{}, len(txs))
|
||||||
|
|
||||||
for i, v := range txs {
|
for i, v := range txs {
|
||||||
ret[i] = v
|
// stripe returns list of transactions ordered by most recent, so reverse the array.
|
||||||
|
ret[len(txs)-1-i] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
listMeta := &stripe.ListMeta{
|
listMeta := &stripe.ListMeta{
|
||||||
|
@ -83,7 +83,7 @@ func (customers *customers) List(ctx context.Context, userIDCursor uuid.UUID, li
|
|||||||
|
|
||||||
rows, err := customers.db.QueryContext(ctx, customers.db.Rebind(`
|
rows, err := customers.db.QueryContext(ctx, customers.db.Rebind(`
|
||||||
SELECT
|
SELECT
|
||||||
stripe_customers.user_id, stripe_customers.customer_id
|
stripe_customers.user_id, stripe_customers.customer_id, stripe_customers.package_plan, stripe_customers.purchased_package_at
|
||||||
FROM
|
FROM
|
||||||
stripe_customers
|
stripe_customers
|
||||||
WHERE
|
WHERE
|
||||||
@ -103,7 +103,7 @@ func (customers *customers) List(ctx context.Context, userIDCursor uuid.UUID, li
|
|||||||
results := []stripe.Customer{}
|
results := []stripe.Customer{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var customer stripe.Customer
|
var customer stripe.Customer
|
||||||
err := rows.Scan(&customer.UserID, &customer.ID)
|
err := rows.Scan(&customer.UserID, &customer.ID, &customer.PackagePlan, &customer.PackagePurchasedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stripe.CustomersPage{}, errs.New("unable to get stripe customer: %+v", err)
|
return stripe.CustomersPage{}, errs.New("unable to get stripe customer: %+v", err)
|
||||||
}
|
}
|
||||||
|
3
scripts/testdata/satellite-config.yaml.lock
vendored
3
scripts/testdata/satellite-config.yaml.lock
vendored
@ -865,6 +865,9 @@ identity.key-path: /root/.local/share/storj/identity/satellite/identity.key
|
|||||||
# the maximum number of concurrent Stripe API calls in invoicing methods
|
# the maximum number of concurrent Stripe API calls in invoicing methods
|
||||||
# payments.stripe-coin-payments.max-parallel-calls: 10
|
# payments.stripe-coin-payments.max-parallel-calls: 10
|
||||||
|
|
||||||
|
# whether to remove expired package credit or not
|
||||||
|
# payments.stripe-coin-payments.remove-expired-credit: true
|
||||||
|
|
||||||
# the duration of the first retry interval
|
# the duration of the first retry interval
|
||||||
# payments.stripe-coin-payments.retries.initial-backoff: 20ms
|
# payments.stripe-coin-payments.retries.initial-backoff: 20ms
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user