diff --git a/cmd/satellite/main.go b/cmd/satellite/main.go index 66a26c2a0..07fa3e850 100644 --- a/cmd/satellite/main.go +++ b/cmd/satellite/main.go @@ -260,12 +260,19 @@ var ( Long: "Finalizes all draft stripe invoices known to satellite's stripe account.", RunE: cmdFinalizeCustomerInvoices, } - payCustomerInvoicesCmd = &cobra.Command{ + payInvoicesWithTokenCmd = &cobra.Command{ + Use: "pay-customer-invoices", + Short: "pay open finalized invoices for customer", + Long: "attempts payment on any open finalized invoices for a specific user.", + Args: cobra.ExactArgs(1), + RunE: cmdPayCustomerInvoices, + } + payAllInvoicesCmd = &cobra.Command{ Use: "pay-invoices", Short: "pay finalized invoices", Long: "attempts payment on all open finalized invoices according to subscriptions settings.", Args: cobra.ExactArgs(1), - RunE: cmdPayCustomerInvoices, + RunE: cmdPayAllInvoices, } stripeCustomerCmd = &cobra.Command{ Use: "ensure-stripe-customer", @@ -404,7 +411,8 @@ func init() { billingCmd.AddCommand(createCustomerInvoicesCmd) billingCmd.AddCommand(generateCustomerInvoicesCmd) billingCmd.AddCommand(finalizeCustomerInvoicesCmd) - billingCmd.AddCommand(payCustomerInvoicesCmd) + billingCmd.AddCommand(payInvoicesWithTokenCmd) + billingCmd.AddCommand(payAllInvoicesCmd) billingCmd.AddCommand(stripeCustomerCmd) consistencyCmd.AddCommand(consistencyGECleanupCmd) process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) @@ -439,7 +447,8 @@ func init() { process.Bind(createCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(generateCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(finalizeCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) - process.Bind(payCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) + process.Bind(payInvoicesWithTokenCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) + process.Bind(payAllInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(stripeCustomerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(consistencyGECleanupCmd, &consistencyGECleanupCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(fixLastNetsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) @@ -869,6 +878,18 @@ func cmdFinalizeCustomerInvoices(cmd *cobra.Command, args []string) (err error) func cmdPayCustomerInvoices(cmd *cobra.Command, args []string) (err error) { ctx, _ := process.Ctx(cmd) + return runBillingCmd(ctx, func(ctx context.Context, payments *stripe.Service, _ satellite.DB) error { + err := payments.InvoiceApplyCustomerTokenBalance(ctx, args[0]) + if err != nil { + return errs.New("error applying native token payments to invoice for customer: %v", err) + } + return payments.PayCustomerInvoices(ctx, args[0]) + }) +} + +func cmdPayAllInvoices(cmd *cobra.Command, args []string) (err error) { + ctx, _ := process.Ctx(cmd) + periodStart, err := parseYearMonth(args[0]) if err != nil { return err diff --git a/satellite/payments/stripe/service.go b/satellite/payments/stripe/service.go index 9411ff778..24d3b4865 100644 --- a/satellite/payments/stripe/service.go +++ b/satellite/payments/stripe/service.go @@ -288,78 +288,42 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context, createdOnA for _, wallet := range wallets { // get the stripe customer invoice balance - cusID, err := service.db.Customers().GetCustomerID(ctx, wallet.UserID) + customerID, err := service.db.Customers().GetCustomerID(ctx, wallet.UserID) if err != nil { errGrp.Add(Error.New("unable to get stripe customer ID for user ID %s", wallet.UserID.String())) continue } - invoices, err := service.getInvoices(ctx, cusID, createdOnAfter) + customerInvoices, err := service.getInvoices(ctx, customerID, createdOnAfter) if err != nil { - errGrp.Add(Error.New("unable to get invoice balance for stripe customer ID %s", cusID)) + errGrp.Add(Error.New("unable to get invoice balance for stripe customer ID %s", customerID)) continue } - for _, invoice := range invoices { - // if no balance due, do nothing - if invoice.AmountRemaining <= 0 { - continue - } - monetaryTokenBalance, err := service.billingDB.GetBalance(ctx, wallet.UserID) - if err != nil { - errGrp.Add(Error.New("unable to get balance for user ID %s", wallet.UserID.String())) - continue - } - // truncate here since stripe only has cent level precision for invoices. - // The users account balance will still maintain the full precision monetary value! - tokenBalance := currency.AmountFromDecimal(monetaryTokenBalance.AsDecimal().Truncate(2), currency.USDollars) - // if token balance is not > 0, don't bother with the rest - if tokenBalance.BaseUnits() <= 0 { - continue - } - - var tokenCreditAmount int64 - if invoice.AmountRemaining >= tokenBalance.BaseUnits() { - tokenCreditAmount = tokenBalance.BaseUnits() - } else { - tokenCreditAmount = invoice.AmountRemaining - } - - txID, err := service.createTokenPaymentBillingTransaction(ctx, wallet.UserID, invoice.ID, wallet.Address.Hex(), -tokenCreditAmount) - if err != nil { - errGrp.Add(Error.New("unable to create token payment billing transaction for user %s", wallet.UserID.String())) - continue - } - - creditNoteID, err := service.addCreditNoteToInvoice(ctx, invoice.ID, cusID, wallet.Address.Hex(), tokenCreditAmount, txID) - if err != nil { - errGrp.Add(Error.New("unable to create token payment credit note for user %s", wallet.UserID.String())) - continue - } - - metadata, err := json.Marshal(map[string]interface{}{ - "Credit Note ID": creditNoteID, - }) - - if err != nil { - errGrp.Add(Error.New("unable to marshall credit note ID %s", creditNoteID)) - continue - } - - err = service.billingDB.UpdateMetadata(ctx, txID, metadata) - if err != nil { - errGrp.Add(Error.New("unable to add credit note ID to billing transaction for user %s", wallet.UserID.String())) - continue - } - - err = service.billingDB.UpdateStatus(ctx, txID, billing.TransactionStatusCompleted) - if err != nil { - errGrp.Add(Error.New("unable to update status for billing transaction for user %s", wallet.UserID.String())) - continue - } + err = service.payInvoicesWithTokenBalance(ctx, customerID, wallet, customerInvoices) + if err != nil { + errGrp.Add(Error.New("unable to pay invoices for stripe customer ID %s", customerID)) + continue } } return errGrp.Err() } +// InvoiceApplyCustomerTokenBalance creates invoice credit notes for the customers token payments to open invoices. +func (service *Service) InvoiceApplyCustomerTokenBalance(ctx context.Context, customerID string) (err error) { + defer mon.Task()(&ctx)(&err) + + userID, err := service.db.Customers().GetUserID(ctx, customerID) + if err != nil { + return Error.New("unable to get user ID for stripe customer ID %s", customerID) + } + + customerInvoices, err := service.getInvoices(ctx, customerID, time.Unix(0, 0)) + if err != nil { + return Error.New("error getting invoices for stripe customer %s", customerID) + } + + return service.PayInvoicesWithTokenBalance(ctx, userID, customerID, customerInvoices) +} + // getInvoices returns the stripe customer's open finalized invoices created on or after the given date. func (service *Service) getInvoices(ctx context.Context, cusID string, createdOnAfter time.Time) (_ []stripe.Invoice, err error) { defer mon.Task()(&ctx)(&err) @@ -378,6 +342,9 @@ func (service *Service) getInvoices(ctx context.Context, cusID string, createdOn stripeInvoices = append(stripeInvoices, *stripeInvoice) } } + if err = invoicesIterator.Err(); err != nil { + return stripeInvoices, Error.Wrap(err) + } return stripeInvoices, nil } @@ -1091,6 +1058,119 @@ func (service *Service) PayInvoices(ctx context.Context, createdOnAfter time.Tim return invoicesIterator.Err() } +// PayCustomerInvoices attempts to transition all open finalized invoices created on or after a certain time to "paid" +// by charging the customer according to subscriptions settings. +func (service *Service) PayCustomerInvoices(ctx context.Context, customerID string) (err error) { + defer mon.Task()(&ctx)(&err) + + customerInvoices, err := service.getInvoices(ctx, customerID, time.Unix(0, 0)) + if err != nil { + return Error.New("error getting invoices for stripe customer %s", customerID) + } + + var errGrp errs.Group + for _, customerInvoice := range customerInvoices { + if customerInvoice.DueDate > 0 { + service.log.Info("Skipping invoice marked for manual payment", + zap.String("id", customerInvoice.ID), + zap.String("number", customerInvoice.Number), + zap.String("customer", customerInvoice.Customer.ID)) + continue + } + + params := &stripe.InvoicePayParams{Params: stripe.Params{Context: ctx}} + _, err = service.stripeClient.Invoices().Pay(customerInvoice.ID, params) + if err != nil { + errGrp.Add(Error.New("unable to pay invoice %s", customerInvoice.ID)) + continue + } + } + return errGrp.Err() +} + +// PayInvoicesWithTokenBalance attempts to transition all the users open invoices to "paid" by charging the customer +// token balance. +func (service *Service) PayInvoicesWithTokenBalance(ctx context.Context, userID uuid.UUID, cusID string, invoices []stripe.Invoice) (err error) { + // get wallet + wallet, err := service.walletsDB.GetWallet(ctx, userID) + if err != nil { + return Error.New("unable to get users in the wallets table") + } + + return service.payInvoicesWithTokenBalance(ctx, cusID, storjscan.Wallet{ + UserID: userID, + Address: wallet, + }, invoices) +} + +// payInvoicesWithTokenBalance attempts to transition the users open invoices to "paid" by charging the customer +// token balance. +func (service *Service) payInvoicesWithTokenBalance(ctx context.Context, cusID string, wallet storjscan.Wallet, invoices []stripe.Invoice) (err error) { + defer mon.Task()(&ctx)(&err) + + var errGrp errs.Group + + for _, invoice := range invoices { + // if no balance due, do nothing + if invoice.AmountRemaining <= 0 { + continue + } + monetaryTokenBalance, err := service.billingDB.GetBalance(ctx, wallet.UserID) + if err != nil { + errGrp.Add(Error.New("unable to get balance for user ID %s", wallet.UserID.String())) + continue + } + // truncate here since stripe only has cent level precision for invoices. + // The users account balance will still maintain the full precision monetary value! + tokenBalance := currency.AmountFromDecimal(monetaryTokenBalance.AsDecimal().Truncate(2), currency.USDollars) + // if token balance is not > 0, don't bother with the rest + if tokenBalance.BaseUnits() <= 0 { + break + } + + var tokenCreditAmount int64 + if invoice.AmountRemaining >= tokenBalance.BaseUnits() { + tokenCreditAmount = tokenBalance.BaseUnits() + } else { + tokenCreditAmount = invoice.AmountRemaining + } + + txID, err := service.createTokenPaymentBillingTransaction(ctx, wallet.UserID, invoice.ID, wallet.Address.Hex(), -tokenCreditAmount) + if err != nil { + errGrp.Add(Error.New("unable to create token payment billing transaction for user %s", wallet.UserID.String())) + continue + } + + creditNoteID, err := service.addCreditNoteToInvoice(ctx, invoice.ID, cusID, wallet.Address.Hex(), tokenCreditAmount, txID) + if err != nil { + errGrp.Add(Error.New("unable to create token payment credit note for user %s", wallet.UserID.String())) + continue + } + + metadata, err := json.Marshal(map[string]interface{}{ + "Credit Note ID": creditNoteID, + }) + + if err != nil { + errGrp.Add(Error.New("unable to marshall credit note ID %s", creditNoteID)) + continue + } + + err = service.billingDB.UpdateMetadata(ctx, txID, metadata) + if err != nil { + errGrp.Add(Error.New("unable to add credit note ID to billing transaction for user %s", wallet.UserID.String())) + continue + } + + err = service.billingDB.UpdateStatus(ctx, txID, billing.TransactionStatusCompleted) + if err != nil { + errGrp.Add(Error.New("unable to update status for billing transaction for user %s", wallet.UserID.String())) + continue + } + } + return errGrp.Err() +} + // projectUsagePrice represents pricing for project usage. type projectUsagePrice struct { Storage decimal.Decimal diff --git a/satellite/payments/stripe/service_test.go b/satellite/payments/stripe/service_test.go index 96f53edbb..b9096d5fe 100644 --- a/satellite/payments/stripe/service_test.go +++ b/satellite/payments/stripe/service_test.go @@ -929,6 +929,154 @@ func TestService_PayMultipleInvoiceFromTokenBalance(t *testing.T) { }) } +func TestService_PayMultipleInvoiceForCustomer(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + satellite := planet.Satellites[0] + + // create user + user, err := satellite.AddUser(ctx, console.CreateUser{ + FullName: "testuser", + Email: "user@test", + }, 1) + require.NoError(t, err) + customer, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID) + require.NoError(t, err) + + amount1 := int64(75) + amount2 := int64(100) + curr := string(stripe.CurrencyUSD) + + // create invoice items for first invoice + inv1Item1, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ + Params: stripe.Params{Context: ctx}, + Amount: &amount1, + Currency: &curr, + Customer: &customer, + }) + require.NoError(t, err) + inv1Item2, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ + Params: stripe.Params{Context: ctx}, + Amount: &amount1, + Currency: &curr, + Customer: &customer, + }) + require.NoError(t, err) + Inv1Items := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 2) + Inv1Items = append(Inv1Items, &stripe.InvoiceUpcomingInvoiceItemParams{ + InvoiceItem: &inv1Item1.ID, + Amount: &amount1, + Currency: &curr, + }) + Inv1Items = append(Inv1Items, &stripe.InvoiceUpcomingInvoiceItemParams{ + InvoiceItem: &inv1Item2.ID, + Amount: &amount1, + Currency: &curr, + }) + + // invoice items for second invoice + inv2Item1, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ + Params: stripe.Params{Context: ctx}, + Amount: &amount2, + Currency: &curr, + Customer: &customer, + }) + require.NoError(t, err) + inv2Item2, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ + Params: stripe.Params{Context: ctx}, + Amount: &amount2, + Currency: &curr, + Customer: &customer, + }) + require.NoError(t, err) + Inv2Items := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 2) + Inv2Items = append(Inv2Items, &stripe.InvoiceUpcomingInvoiceItemParams{ + InvoiceItem: &inv2Item1.ID, + Amount: &amount2, + Currency: &curr, + }) + Inv2Items = append(Inv2Items, &stripe.InvoiceUpcomingInvoiceItemParams{ + InvoiceItem: &inv2Item2.ID, + Amount: &amount2, + Currency: &curr, + }) + + // create invoice one + inv1, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{ + Params: stripe.Params{Context: ctx}, + Customer: &customer, + InvoiceItems: Inv1Items, + DefaultPaymentMethod: stripe.String(stripe1.MockInvoicesPaySuccess), + }) + require.NoError(t, err) + + // create invoice two + inv2, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{ + Params: stripe.Params{Context: ctx}, + Customer: &customer, + InvoiceItems: Inv2Items, + DefaultPaymentMethod: stripe.String(stripe1.MockInvoicesPaySuccess), + }) + require.NoError(t, err) + + finalizeParams := &stripe.InvoiceFinalizeParams{Params: stripe.Params{Context: ctx}} + + // finalize invoice one + inv1, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv1.ID, finalizeParams) + require.NoError(t, err) + require.Equal(t, stripe.InvoiceStatusOpen, inv1.Status) + + // finalize invoice two + inv2, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv2.ID, finalizeParams) + inv2.Metadata = map[string]string{"PaymentMethod": stripe1.MockInvoicesPaySuccess} + require.NoError(t, err) + require.Equal(t, stripe.InvoiceStatusOpen, inv2.Status) + + // setup storjscan wallet and user balance + address, err := blockchain.BytesToAddress(testrand.Bytes(20)) + require.NoError(t, err) + userID := user.ID + err = satellite.DB.Wallets().Add(ctx, userID, address) + require.NoError(t, err) + // User balance is not enough to cover full amount of both invoices + _, err = satellite.DB.Billing().Insert(ctx, billing.Transaction{ + UserID: userID, + Amount: currency.AmountFromBaseUnits(300, currency.USDollars), + Description: "token payment credit", + Source: billing.StorjScanSource, + Status: billing.TransactionStatusCompleted, + Type: billing.TransactionTypeCredit, + Metadata: nil, + Timestamp: time.Now(), + CreatedAt: time.Now(), + }) + require.NoError(t, err) + + // attempt to pay user invoices, CC should be used to cover remainder after token balance + err = satellite.API.Payments.StripeService.InvoiceApplyCustomerTokenBalance(ctx, customer) + require.NoError(t, err) + err = satellite.API.Payments.StripeService.PayCustomerInvoices(ctx, customer) + require.NoError(t, err) + + iter := satellite.API.Payments.StripeClient.Invoices().List(&stripe.InvoiceListParams{ + ListParams: stripe.ListParams{Context: ctx}, + }) + var stripeInvoices []*stripe.Invoice + for iter.Next() { + stripeInvoices = append(stripeInvoices, iter.Invoice()) + } + require.Equal(t, 2, len(stripeInvoices)) + require.Equal(t, stripe.InvoiceStatusPaid, stripeInvoices[0].Status) + require.Equal(t, stripe.InvoiceStatusPaid, stripeInvoices[1].Status) + require.NoError(t, iter.Err()) + balance, err := satellite.DB.Billing().GetBalance(ctx, userID) + require.NoError(t, err) + require.False(t, balance.IsNegative()) + require.Zero(t, balance.BaseUnits()) + }) +} + func TestService_GenerateInvoice(t *testing.T) { for _, testCase := range []struct { desc string