From efcae857ba859dfde055aae4b008c97e96c777b1 Mon Sep 17 00:00:00 2001 From: dlamarmorgan Date: Thu, 20 Apr 2023 13:51:19 -0700 Subject: [PATCH] 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 --- cmd/satellite/main.go | 16 ++++++ satellite/payments/stripe/client.go | 2 + satellite/payments/stripe/service.go | 54 ++++++++++++++++++++ satellite/payments/stripe/service_test.go | 62 +++++++++++++++++++++++ satellite/payments/stripe/stripemock.go | 23 ++++++++- 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/cmd/satellite/main.go b/cmd/satellite/main.go index 591c36149..5e9ed6cab 100644 --- a/cmd/satellite/main.go +++ b/cmd/satellite/main.go @@ -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) diff --git a/satellite/payments/stripe/client.go b/satellite/payments/stripe/client.go index 3445ce949..0c37e1a44 100644 --- a/satellite/payments/stripe/client.go +++ b/satellite/payments/stripe/client.go @@ -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. diff --git a/satellite/payments/stripe/service.go b/satellite/payments/stripe/service.go index 33ade9d46..d8f430d17 100644 --- a/satellite/payments/stripe/service.go +++ b/satellite/payments/stripe/service.go @@ -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. diff --git a/satellite/payments/stripe/service_test.go b/satellite/payments/stripe/service_test.go index e1b156919..06ea1f0f7 100644 --- a/satellite/payments/stripe/service_test.go +++ b/satellite/payments/stripe/service_test.go @@ -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, diff --git a/satellite/payments/stripe/stripemock.go b/satellite/payments/stripe/stripemock.go index 615b8e12e..949d1ca44 100644 --- a/satellite/payments/stripe/stripemock.go +++ b/satellite/payments/stripe/stripemock.go @@ -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 }