satellite/main,stripe/{client,service}: stripe balance invoice item cmd

Add a new billing command that will convert stripe customer balances into invoice items so that the charges can be processed normally by the invoicing workflow.

Change-Id: Iaa8350e7aca80a0f14e94eb8ef8b7d6ce0b5b3b8
This commit is contained in:
dlamarmorgan 2023-04-20 13:51:19 -07:00 committed by Storj Robot
parent 5fc493b276
commit efcae857ba
5 changed files with 156 additions and 1 deletions

View File

@ -208,6 +208,12 @@ var (
Long: "Applies free tier coupon to Stripe customers without a coupon",
RunE: cmdApplyFreeTierCoupons,
}
createCustomerBalanceInvoiceItems = &cobra.Command{
Use: "create-balance-invoice-items",
Short: "Creates stripe invoice line items for stripe customer balance",
Long: "Creates stripe invoice line items for stripe customer balances obtained from past invoices and other miscellaneous charges.",
RunE: cmdCreateCustomerBalanceInvoiceItems,
}
prepareCustomerInvoiceRecordsCmd = &cobra.Command{
Use: "prepare-invoice-records [period]",
Short: "Prepares invoice project records",
@ -375,6 +381,7 @@ func init() {
compensationCmd.AddCommand(recordPeriodCmd)
compensationCmd.AddCommand(recordOneOffPaymentsCmd)
billingCmd.AddCommand(applyFreeTierCouponsCmd)
billingCmd.AddCommand(createCustomerBalanceInvoiceItems)
billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd)
billingCmd.AddCommand(createCustomerProjectInvoiceItemsCmd)
billingCmd.AddCommand(createCustomerInvoicesCmd)
@ -406,6 +413,7 @@ func init() {
process.Bind(reportsVerifyGEReceiptCmd, &reportsVerifyGracefulExitReceiptCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(partnerAttributionCmd, &partnerAttribtionCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(applyFreeTierCouponsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerBalanceInvoiceItems, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(prepareCustomerInvoiceRecordsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerProjectInvoiceItemsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(createCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
@ -746,6 +754,14 @@ func cmdValueAttribution(cmd *cobra.Command, args []string) (err error) {
return reports.GenerateAttributionCSV(ctx, partnerAttribtionCfg.Database, start, end, userAgents, file)
}
func cmdCreateCustomerBalanceInvoiceItems(cmd *cobra.Command, _ []string) (err error) {
ctx, _ := process.Ctx(cmd)
return runBillingCmd(ctx, func(ctx context.Context, payments *stripe.Service, _ satellite.DB) error {
return payments.CreateBalanceInvoiceItems(ctx)
})
}
func cmdPrepareCustomerInvoiceRecords(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)

View File

@ -15,6 +15,7 @@ import (
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/charge"
"github.com/stripe/stripe-go/v72/client"
"github.com/stripe/stripe-go/v72/customer"
"github.com/stripe/stripe-go/v72/customerbalancetransaction"
"github.com/stripe/stripe-go/v72/form"
"github.com/stripe/stripe-go/v72/invoice"
@ -43,6 +44,7 @@ type Customers interface {
New(params *stripe.CustomerParams) (*stripe.Customer, error)
Get(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
Update(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
List(listParams *stripe.CustomerListParams) *customer.Iter
}
// PaymentMethods Stripe PaymentMethods interface.

View File

@ -774,6 +774,60 @@ func (service *Service) createInvoices(ctx context.Context, customers []Customer
return scheduled, draft, errGrp.Err()
}
// CreateBalanceInvoiceItems will find users with a stripe balance, create an invoice
// item with the charges due, and zero out the stripe balance.
func (service *Service) CreateBalanceInvoiceItems(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
custListParams := &stripe.CustomerListParams{
ListParams: stripe.ListParams{
Context: ctx,
Limit: stripe.Int64(100),
},
}
var errGrp errs.Group
itr := service.stripeClient.Customers().List(custListParams)
for itr.Next() {
if itr.Customer().Balance <= 0 {
continue
}
service.log.Info("Creating invoice item for customer prior balance", zap.String("CustomerID", itr.Customer().ID))
itemParams := &stripe.InvoiceItemParams{
Params: stripe.Params{
Context: ctx,
},
Currency: stripe.String(string(stripe.CurrencyUSD)),
Customer: stripe.String(itr.Customer().ID),
Description: stripe.String("Prior Stripe Customer Balance"),
Quantity: stripe.Int64(1),
UnitAmount: stripe.Int64(itr.Customer().Balance),
}
invoiceItem, err := service.stripeClient.InvoiceItems().New(itemParams)
if err != nil {
service.log.Error("Failed to add invoice item for customer prior balance", zap.Error(err))
errGrp.Add(err)
continue
}
service.log.Info("Updating customer balance to 0", zap.String("CustomerID", itr.Customer().ID))
custParams := &stripe.CustomerParams{
Params: stripe.Params{
Context: ctx,
},
Balance: stripe.Int64(0),
Description: stripe.String("Customer balance adjusted to 0 after adding invoice item " + invoiceItem.ID),
}
_, err = service.stripeClient.Customers().Update(itr.Customer().ID, custParams)
if err != nil {
service.log.Error("Failed to update customer balance to 0 after adding invoice item", zap.Error(err))
errGrp.Add(err)
continue
}
service.log.Info("Customer successfully updated", zap.String("CustomerID", itr.Customer().ID), zap.Int64("Prior Balance", itr.Customer().Balance), zap.Int64("New Balance", 0), zap.String("InvoiceItemID", invoiceItem.ID))
}
return errGrp.Err()
}
// GenerateInvoices performs all tasks necessary to generate Stripe invoices.
// This is equivalent to invoking ApplyFreeTierCoupons, PrepareInvoiceProjectRecords,
// InvoiceApplyProjectRecords, and CreateInvoices in order.

View File

@ -35,6 +35,68 @@ import (
stripe1 "storj.io/storj/satellite/payments/stripe"
)
func TestService_BalanceInvoiceItems(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Payments.StripeCoinPayments.ListingLimit = 4
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
satellite := planet.Satellites[0]
payments := satellite.API.Payments
numberOfUsers := 10
users := make([]*console.User, numberOfUsers)
projects := make([]*console.Project, numberOfUsers)
// create a bunch of users
for i := 0; i < numberOfUsers; i++ {
var err error
users[i], err = satellite.AddUser(ctx, console.CreateUser{
FullName: "testuser" + strconv.Itoa(i),
Email: "user@test" + strconv.Itoa(i),
}, 1)
require.NoError(t, err)
projects[i], err = satellite.AddProject(ctx, users[i].ID, "testproject-"+strconv.Itoa(i))
require.NoError(t, err)
}
// give one of the users a stripe balance
cusID, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, users[4].ID)
require.NoError(t, err)
_, err = payments.StripeClient.Customers().Update(cusID, &stripe.CustomerParams{
Params: stripe.Params{
Context: ctx,
},
Balance: stripe.Int64(1000),
})
require.NoError(t, err)
// convert the stripe balance into an invoice item
require.NoError(t, payments.StripeService.CreateBalanceInvoiceItems(ctx))
// check that the invoice item was created
itr := payments.StripeClient.InvoiceItems().List(&stripe.InvoiceItemListParams{
Customer: stripe.String(cusID),
})
require.True(t, itr.Next())
require.NoError(t, itr.Err())
require.Equal(t, int64(1000), itr.InvoiceItem().UnitAmount)
// check that the stripe balance was reset
cus, err := payments.StripeClient.Customers().Get(cusID, &stripe.CustomerParams{
Params: stripe.Params{
Context: ctx,
},
})
require.NoError(t, err)
require.Equal(t, int64(0), cus.Balance)
})
}
func TestService_InvoiceElementsProcessing(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,

View File

@ -12,6 +12,7 @@ import (
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/charge"
"github.com/stripe/stripe-go/v72/customer"
"github.com/stripe/stripe-go/v72/customerbalancetransaction"
"github.com/stripe/stripe-go/v72/form"
"github.com/stripe/stripe-go/v72/invoice"
@ -194,6 +195,21 @@ type mockCustomers struct {
coupons map[string]*stripe.Coupon
}
func (m *mockCustomers) List(listParams *stripe.CustomerListParams) *customer.Iter {
m.root.mu.Lock()
defer m.root.mu.Unlock()
return &customer.Iter{
Iter: stripe.GetIter(listParams, func(p *stripe.Params, vals *form.Values) ([]interface{}, stripe.ListContainer, error) {
var customers []interface{}
for _, cus := range m.state.customers {
customers = append(customers, cus)
}
return customers, newListContainer(&stripe.ListMeta{}), nil
}),
}
}
type mockCustomersState struct {
customers []*stripe.Customer
repopulated bool
@ -332,7 +348,9 @@ func (m *mockCustomers) Update(id string, params *stripe.CustomerParams) (*strip
}
customer.Discount = &stripe.Discount{Coupon: &stripe.Coupon{ID: c.ID}}
}
if params.Balance != nil {
customer.Balance = *params.Balance
}
// TODO update customer with more params as necessary
return customer, nil
@ -661,6 +679,9 @@ func (m *mockInvoiceItems) New(params *stripe.InvoiceItemParams) (*stripe.Invoic
if params.UnitAmountDecimal != nil {
item.UnitAmountDecimal = *params.UnitAmountDecimal
}
if params.UnitAmount != nil {
item.UnitAmount = *params.UnitAmount
}
if params.Amount != nil {
item.Amount = *params.Amount
}