diff --git a/cmd/satellite/main.go b/cmd/satellite/main.go index 19f0692c0..62f1e2c6f 100644 --- a/cmd/satellite/main.go +++ b/cmd/satellite/main.go @@ -213,12 +213,6 @@ var ( Args: cobra.ExactArgs(1), RunE: cmdCreateCustomerProjectInvoiceItems, } - createCustomerTokenInvoiceItemsCmd = &cobra.Command{ - Use: "create-token-invoice-items [period]", - Short: "Creates stripe invoice line items for token payments", - Long: "Creates stripe invoice line items for unapplied token balances.", - RunE: cmdCreateCustomerTokenInvoiceItems, - } createCustomerInvoicesCmd = &cobra.Command{ Use: "create-invoices [period]", Short: "Creates stripe invoices from pending invoice items", @@ -232,6 +226,12 @@ var ( Long: "Finalizes all draft stripe invoices known to satellite's stripe account.", RunE: cmdFinalizeCustomerInvoices, } + payCustomerInvoicesCmd = &cobra.Command{ + Use: "pay-invoices", + Short: "pay finalized invoices", + Long: "attempts payment on all open finalized invoices according to subscriptions settings.", + RunE: cmdPayCustomerInvoices, + } stripeCustomerCmd = &cobra.Command{ Use: "ensure-stripe-customer", Short: "Ensures that we have a stripe customer for every user", @@ -345,9 +345,9 @@ func init() { billingCmd.AddCommand(applyFreeTierCouponsCmd) billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd) billingCmd.AddCommand(createCustomerProjectInvoiceItemsCmd) - billingCmd.AddCommand(createCustomerTokenInvoiceItemsCmd) billingCmd.AddCommand(createCustomerInvoicesCmd) billingCmd.AddCommand(finalizeCustomerInvoicesCmd) + billingCmd.AddCommand(payCustomerInvoicesCmd) billingCmd.AddCommand(stripeCustomerCmd) consistencyCmd.AddCommand(consistencyGECleanupCmd) process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) @@ -372,9 +372,9 @@ func init() { process.Bind(applyFreeTierCouponsCmd, &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(createCustomerTokenInvoiceItemsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(createCustomerInvoicesCmd, &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(stripeCustomerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) process.Bind(consistencyGECleanupCmd, &consistencyGECleanupCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) @@ -747,14 +747,6 @@ func cmdCreateCustomerProjectInvoiceItems(cmd *cobra.Command, args []string) (er }) } -func cmdCreateCustomerTokenInvoiceItems(cmd *cobra.Command, args []string) (err error) { - ctx, _ := process.Ctx(cmd) - - return runBillingCmd(ctx, func(ctx context.Context, payments *stripecoinpayments.Service, _ satellite.DB) error { - return payments.InvoiceApplyTokenBalance(ctx) - }) -} - func cmdCreateCustomerInvoices(cmd *cobra.Command, args []string) (err error) { ctx, _ := process.Ctx(cmd) @@ -776,6 +768,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 *stripecoinpayments.Service, _ satellite.DB) error { + err := payments.InvoiceApplyTokenBalance(ctx) + if err != nil { + return errs.New("error applying native token payments: %v", err) + } + return payments.PayInvoices(ctx) + }) +} + func cmdStripeCustomer(cmd *cobra.Command, args []string) (err error) { ctx, _ := process.Ctx(cmd) diff --git a/satellite/payments/stripecoinpayments/client.go b/satellite/payments/stripecoinpayments/client.go index 8b06e2860..3753bcf9a 100644 --- a/satellite/payments/stripecoinpayments/client.go +++ b/satellite/payments/stripecoinpayments/client.go @@ -24,6 +24,7 @@ type StripeClient interface { CustomerBalanceTransactions() StripeCustomerBalanceTransactions Charges() StripeCharges PromoCodes() StripePromoCodes + CreditNotes() StripeCreditNotes } // StripeCustomers Stripe Customers interface. @@ -46,6 +47,7 @@ type StripeInvoices interface { New(params *stripe.InvoiceParams) (*stripe.Invoice, error) List(listParams *stripe.InvoiceListParams) *invoice.Iter FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error) + Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error) } // StripeInvoiceItems Stripe InvoiceItems interface. @@ -72,6 +74,11 @@ type StripeCustomerBalanceTransactions interface { List(listParams *stripe.CustomerBalanceTransactionListParams) *customerbalancetransaction.Iter } +// StripeCreditNotes Stripe CreditNotes interface. +type StripeCreditNotes interface { + New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error) +} + type stripeClient struct { client *client.API } @@ -104,6 +111,10 @@ func (s *stripeClient) PromoCodes() StripePromoCodes { return s.client.PromotionCodes } +func (s *stripeClient) CreditNotes() StripeCreditNotes { + return s.client.CreditNotes +} + // NewStripeClient creates Stripe client from configuration. func NewStripeClient(log *zap.Logger, config Config) StripeClient { backendConfig := &stripe.BackendConfig{ diff --git a/satellite/payments/stripecoinpayments/service.go b/satellite/payments/stripecoinpayments/service.go index 539cb6522..0ef412062 100644 --- a/satellite/payments/stripecoinpayments/service.go +++ b/satellite/payments/stripecoinpayments/service.go @@ -581,41 +581,47 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context) (err error } for _, invoice := range invoices { // if no balance due, do nothing - if invoice.AmountDue <= 0 { + if invoice.AmountRemaining <= 0 { continue } var tokenCreditAmount int64 - if invoice.AmountDue >= tokenBalance.BaseUnits() { - tokenCreditAmount = -tokenBalance.BaseUnits() + if invoice.AmountRemaining >= tokenBalance.BaseUnits() { + tokenCreditAmount = tokenBalance.BaseUnits() } else { - tokenCreditAmount = -invoice.AmountDue + tokenCreditAmount = invoice.AmountRemaining } - txID, err := service.createTokenPaymentBillingTransaction(ctx, wallet.UserID, invoice.ID, wallet.Address.Hex(), tokenCreditAmount) + 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 } - invoiceItem, err := service.createTokenPaymentInvoiceItem(ctx, cusID, tokenCreditAmount, txID, wallet.Address.Hex()) + 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 invoice item for user %s", wallet.UserID.String())) + 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{}{ - "ItemID": invoiceItem.ID, + "Credit Note ID": creditNoteID, }) if err != nil { - errGrp.Add(Error.New("unable to marshall invoice item ID %s", invoiceItem.ID)) + 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 invoice item ID to billing transaction for user %s", wallet.UserID.String())) + 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 } } @@ -623,13 +629,13 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context) (err error return errGrp.Err() } -// getInvoiceBalance returns the stripe customer's current invoices. +// getInvoices returns the stripe customer's open finalized invoices. func (service *Service) getInvoices(ctx context.Context, cusID string) (_ []stripe.Invoice, err error) { defer mon.Task()(&ctx)(&err) params := &stripe.InvoiceListParams{ Customer: stripe.String(cusID), - Status: stripe.String(string(stripe.InvoiceStatusDraft)), + Status: stripe.String(string(stripe.InvoiceStatusOpen)), } invoicesIterator := service.stripeClient.Invoices().List(params) var stripeInvoices []stripe.Invoice @@ -642,29 +648,34 @@ func (service *Service) getInvoices(ctx context.Context, cusID string) (_ []stri return stripeInvoices, nil } -// createTokenPaymentInvoiceItem creates an invoice line item for the user token payment. -func (service *Service) createTokenPaymentInvoiceItem(ctx context.Context, cusID string, amount int64, txID int64, wallet string) (invoiceItem *stripe.InvoiceItem, err error) { +// addCreditNoteToInvoice creates a credit note for the user token payment. +func (service *Service) addCreditNoteToInvoice(ctx context.Context, invoiceID, cusID, wallet string, amount, txID int64) (_ string, err error) { defer mon.Task()(&ctx)(&err) - // add an invoice item for the total invoice amount - tokenCredit := &stripe.InvoiceItemParams{ - Currency: stripe.String(string(stripe.CurrencyUSD)), - Customer: stripe.String(cusID), - Description: stripe.String("payment from tokens"), + var lineParams []*stripe.CreditNoteLineParams + + lineParam := stripe.CreditNoteLineParams{ + Description: stripe.String("Storjscan Token payment"), + Type: stripe.String("custom_line_item"), UnitAmount: stripe.Int64(amount), - Params: stripe.Params{ - Metadata: map[string]string{ - "transaction ID": strconv.FormatInt(txID, 10), - "wallet address": wallet, - }, - }, + Quantity: stripe.Int64(1), } - invoiceItem, err = service.stripeClient.InvoiceItems().New(tokenCredit) + + lineParams = append(lineParams, &lineParam) + + params := &stripe.CreditNoteParams{ + Invoice: stripe.String(invoiceID), + Lines: lineParams, + Memo: stripe.String("Storjscan Token Payment - Wallet: 0x" + wallet), + } + params.AddMetadata("txID", "0x"+strconv.FormatInt(txID, 10)) + params.AddMetadata("wallet address", wallet) + creditNote, err := service.stripeClient.CreditNotes().New(params) if err != nil { - service.log.Warn("unable to add invoice item for stripe customer", zap.String("Customer ID", cusID)) - return nil, Error.Wrap(err) + service.log.Warn("unable to add credit note for stripe customer", zap.String("Customer ID", cusID)) + return "", Error.Wrap(err) } - return + return creditNote.ID, nil } // createTokenPaymentBillingTransaction creates a billing DB entry for the user token payment. @@ -918,7 +929,7 @@ func (service *Service) createInvoice(ctx context.Context, cusID string, period return nil } -// FinalizeInvoices sets autoadvance flag on all draft invoices currently available in stripe. +// FinalizeInvoices transitions all draft invoices to open finalized invoices in stripe. No payment is to be collected yet. func (service *Service) FinalizeInvoices(ctx context.Context) (err error) { defer mon.Task()(&ctx)(&err) @@ -934,16 +945,6 @@ func (service *Service) FinalizeInvoices(ctx context.Context) (err error) { if err != nil { return Error.Wrap(err) } - if transactionID, ok := stripeInvoice.Metadata["transaction ID"]; ok { - txID, err := strconv.ParseInt(transactionID, 10, 64) - if err != nil { - return Error.Wrap(err) - } - err = service.billingDB.UpdateStatus(ctx, txID, billing.TransactionStatusCompleted) - if err != nil { - return Error.Wrap(err) - } - } } return Error.Wrap(invoicesIterator.Err()) @@ -952,11 +953,35 @@ func (service *Service) FinalizeInvoices(ctx context.Context) (err error) { func (service *Service) finalizeInvoice(ctx context.Context, invoiceID string) (err error) { defer mon.Task()(&ctx)(&err) - params := &stripe.InvoiceFinalizeParams{AutoAdvance: stripe.Bool(true)} + params := &stripe.InvoiceFinalizeParams{AutoAdvance: stripe.Bool(false)} _, err = service.stripeClient.Invoices().FinalizeInvoice(invoiceID, params) return err } +// PayInvoices attempts to transition all open finalized invoices to "paid" by charging the customer according to subscriptions settings. +func (service *Service) PayInvoices(ctx context.Context) (err error) { + defer mon.Task()(&ctx)(&err) + + params := &stripe.InvoiceListParams{ + Status: stripe.String("open"), + } + + var errGrp errs.Group + + invoicesIterator := service.stripeClient.Invoices().List(params) + for invoicesIterator.Next() { + stripeInvoice := invoicesIterator.Invoice() + + params := &stripe.InvoicePayParams{} + _, err = service.stripeClient.Invoices().Pay(stripeInvoice.ID, params) + if err != nil { + errGrp.Add(Error.New("unable to pay invoice %s", stripeInvoice.ID)) + continue + } + } + return errGrp.Err() +} + // projectUsagePrice represents pricing for project usage. type projectUsagePrice struct { Storage decimal.Decimal diff --git a/satellite/payments/stripecoinpayments/stripemock.go b/satellite/payments/stripecoinpayments/stripemock.go index 5c39d4442..0c385d37e 100644 --- a/satellite/payments/stripecoinpayments/stripemock.go +++ b/satellite/payments/stripecoinpayments/stripemock.go @@ -80,6 +80,7 @@ type mockStripeState struct { customerBalanceTransactions *mockCustomerBalanceTransactions charges *mockCharges promoCodes *mockPromoCodes + creditNotes *mockCreditNotes } type mockStripeClient struct { @@ -164,6 +165,10 @@ func (m *mockStripeClient) PromoCodes() StripePromoCodes { return m.promoCodes } +func (m *mockStripeClient) CreditNotes() StripeCreditNotes { + return m.creditNotes +} + type mockCustomers struct { customersDB CustomersDB usersDB console.Users @@ -430,6 +435,10 @@ func (m *mockInvoices) FinalizeInvoice(id string, params *stripe.InvoiceFinalize return nil, nil } +func (m *mockInvoices) Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error) { + return nil, nil +} + type mockInvoiceItems struct { } @@ -534,3 +543,10 @@ func (m *mockPromoCodes) List(params *stripe.PromotionCodeListParams) *promotion return &promotioncode.Iter{Iter: stripe.GetIter(params, query)} } + +type mockCreditNotes struct { +} + +func (m mockCreditNotes) New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error) { + return nil, nil +}