satellite/payments/stripecoinpayments: update invoicing to use credit note

Rather than using Invoice Items to account for storjscan token payments, credit notes will be used and applied to the users finalized invoice. This credit note will reduce the amount due of the users invoice based on the amount of storj token balance the user has on the satellite. Applying credit notes to a finalized invoice also requires that the invoice not be automatically paid when finalized. Therefore, a new command (pay-invoices) was added to initiate payment for users invoices.

Change-Id: Ie539375a10e842e3cb64bf0140834bbab0774f54
This commit is contained in:
dlamarmorgan 2022-09-12 16:16:17 -07:00 committed by Yaroslav Vorobiov
parent a3d9630336
commit a3a3ffd123
4 changed files with 113 additions and 57 deletions

View File

@ -213,12 +213,6 @@ var (
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: cmdCreateCustomerProjectInvoiceItems, 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{ createCustomerInvoicesCmd = &cobra.Command{
Use: "create-invoices [period]", Use: "create-invoices [period]",
Short: "Creates stripe invoices from pending invoice items", 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.", Long: "Finalizes all draft stripe invoices known to satellite's stripe account.",
RunE: cmdFinalizeCustomerInvoices, 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{ stripeCustomerCmd = &cobra.Command{
Use: "ensure-stripe-customer", Use: "ensure-stripe-customer",
Short: "Ensures that we have a stripe customer for every user", Short: "Ensures that we have a stripe customer for every user",
@ -345,9 +345,9 @@ func init() {
billingCmd.AddCommand(applyFreeTierCouponsCmd) billingCmd.AddCommand(applyFreeTierCouponsCmd)
billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd) billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd)
billingCmd.AddCommand(createCustomerProjectInvoiceItemsCmd) billingCmd.AddCommand(createCustomerProjectInvoiceItemsCmd)
billingCmd.AddCommand(createCustomerTokenInvoiceItemsCmd)
billingCmd.AddCommand(createCustomerInvoicesCmd) billingCmd.AddCommand(createCustomerInvoicesCmd)
billingCmd.AddCommand(finalizeCustomerInvoicesCmd) billingCmd.AddCommand(finalizeCustomerInvoicesCmd)
billingCmd.AddCommand(payCustomerInvoicesCmd)
billingCmd.AddCommand(stripeCustomerCmd) billingCmd.AddCommand(stripeCustomerCmd)
consistencyCmd.AddCommand(consistencyGECleanupCmd) consistencyCmd.AddCommand(consistencyGECleanupCmd)
process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir)) 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(applyFreeTierCouponsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(prepareCustomerInvoiceRecordsCmd, &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(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(createCustomerInvoicesCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(finalizeCustomerInvoicesCmd, &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(stripeCustomerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
process.Bind(consistencyGECleanupCmd, &consistencyGECleanupCfg, 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) { func cmdCreateCustomerInvoices(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd) 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) { func cmdStripeCustomer(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd) ctx, _ := process.Ctx(cmd)

View File

@ -24,6 +24,7 @@ type StripeClient interface {
CustomerBalanceTransactions() StripeCustomerBalanceTransactions CustomerBalanceTransactions() StripeCustomerBalanceTransactions
Charges() StripeCharges Charges() StripeCharges
PromoCodes() StripePromoCodes PromoCodes() StripePromoCodes
CreditNotes() StripeCreditNotes
} }
// StripeCustomers Stripe Customers interface. // StripeCustomers Stripe Customers interface.
@ -46,6 +47,7 @@ type StripeInvoices interface {
New(params *stripe.InvoiceParams) (*stripe.Invoice, error) New(params *stripe.InvoiceParams) (*stripe.Invoice, error)
List(listParams *stripe.InvoiceListParams) *invoice.Iter List(listParams *stripe.InvoiceListParams) *invoice.Iter
FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error) FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error)
Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error)
} }
// StripeInvoiceItems Stripe InvoiceItems interface. // StripeInvoiceItems Stripe InvoiceItems interface.
@ -72,6 +74,11 @@ type StripeCustomerBalanceTransactions interface {
List(listParams *stripe.CustomerBalanceTransactionListParams) *customerbalancetransaction.Iter List(listParams *stripe.CustomerBalanceTransactionListParams) *customerbalancetransaction.Iter
} }
// StripeCreditNotes Stripe CreditNotes interface.
type StripeCreditNotes interface {
New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error)
}
type stripeClient struct { type stripeClient struct {
client *client.API client *client.API
} }
@ -104,6 +111,10 @@ func (s *stripeClient) PromoCodes() StripePromoCodes {
return s.client.PromotionCodes return s.client.PromotionCodes
} }
func (s *stripeClient) CreditNotes() StripeCreditNotes {
return s.client.CreditNotes
}
// NewStripeClient creates Stripe client from configuration. // NewStripeClient creates Stripe client from configuration.
func NewStripeClient(log *zap.Logger, config Config) StripeClient { func NewStripeClient(log *zap.Logger, config Config) StripeClient {
backendConfig := &stripe.BackendConfig{ backendConfig := &stripe.BackendConfig{

View File

@ -581,41 +581,47 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context) (err error
} }
for _, invoice := range invoices { for _, invoice := range invoices {
// if no balance due, do nothing // if no balance due, do nothing
if invoice.AmountDue <= 0 { if invoice.AmountRemaining <= 0 {
continue continue
} }
var tokenCreditAmount int64 var tokenCreditAmount int64
if invoice.AmountDue >= tokenBalance.BaseUnits() { if invoice.AmountRemaining >= tokenBalance.BaseUnits() {
tokenCreditAmount = -tokenBalance.BaseUnits() tokenCreditAmount = tokenBalance.BaseUnits()
} else { } 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 { if err != nil {
errGrp.Add(Error.New("unable to create token payment billing transaction for user %s", wallet.UserID.String())) errGrp.Add(Error.New("unable to create token payment billing transaction for user %s", wallet.UserID.String()))
continue 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 { 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 continue
} }
metadata, err := json.Marshal(map[string]interface{}{ metadata, err := json.Marshal(map[string]interface{}{
"ItemID": invoiceItem.ID, "Credit Note ID": creditNoteID,
}) })
if err != nil { 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 continue
} }
err = service.billingDB.UpdateMetadata(ctx, txID, metadata) err = service.billingDB.UpdateMetadata(ctx, txID, metadata)
if err != nil { 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 continue
} }
} }
@ -623,13 +629,13 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context) (err error
return errGrp.Err() 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) { func (service *Service) getInvoices(ctx context.Context, cusID string) (_ []stripe.Invoice, err error) {
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
params := &stripe.InvoiceListParams{ params := &stripe.InvoiceListParams{
Customer: stripe.String(cusID), Customer: stripe.String(cusID),
Status: stripe.String(string(stripe.InvoiceStatusDraft)), Status: stripe.String(string(stripe.InvoiceStatusOpen)),
} }
invoicesIterator := service.stripeClient.Invoices().List(params) invoicesIterator := service.stripeClient.Invoices().List(params)
var stripeInvoices []stripe.Invoice var stripeInvoices []stripe.Invoice
@ -642,29 +648,34 @@ func (service *Service) getInvoices(ctx context.Context, cusID string) (_ []stri
return stripeInvoices, nil return stripeInvoices, nil
} }
// createTokenPaymentInvoiceItem creates an invoice line item for the user token payment. // addCreditNoteToInvoice creates a credit note 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) { func (service *Service) addCreditNoteToInvoice(ctx context.Context, invoiceID, cusID, wallet string, amount, txID int64) (_ string, err error) {
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
// add an invoice item for the total invoice amount var lineParams []*stripe.CreditNoteLineParams
tokenCredit := &stripe.InvoiceItemParams{
Currency: stripe.String(string(stripe.CurrencyUSD)), lineParam := stripe.CreditNoteLineParams{
Customer: stripe.String(cusID), Description: stripe.String("Storjscan Token payment"),
Description: stripe.String("payment from tokens"), Type: stripe.String("custom_line_item"),
UnitAmount: stripe.Int64(amount), UnitAmount: stripe.Int64(amount),
Params: stripe.Params{ Quantity: stripe.Int64(1),
Metadata: map[string]string{
"transaction ID": strconv.FormatInt(txID, 10),
"wallet address": wallet,
},
},
} }
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 { if err != nil {
service.log.Warn("unable to add invoice item for stripe customer", zap.String("Customer ID", cusID)) service.log.Warn("unable to add credit note for stripe customer", zap.String("Customer ID", cusID))
return nil, Error.Wrap(err) return "", Error.Wrap(err)
} }
return return creditNote.ID, nil
} }
// createTokenPaymentBillingTransaction creates a billing DB entry for the user token payment. // 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 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) { func (service *Service) FinalizeInvoices(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err) defer mon.Task()(&ctx)(&err)
@ -934,16 +945,6 @@ func (service *Service) FinalizeInvoices(ctx context.Context) (err error) {
if err != nil { if err != nil {
return Error.Wrap(err) 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()) 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) { func (service *Service) finalizeInvoice(ctx context.Context, invoiceID string) (err error) {
defer mon.Task()(&ctx)(&err) 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) _, err = service.stripeClient.Invoices().FinalizeInvoice(invoiceID, params)
return err 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. // projectUsagePrice represents pricing for project usage.
type projectUsagePrice struct { type projectUsagePrice struct {
Storage decimal.Decimal Storage decimal.Decimal

View File

@ -80,6 +80,7 @@ type mockStripeState struct {
customerBalanceTransactions *mockCustomerBalanceTransactions customerBalanceTransactions *mockCustomerBalanceTransactions
charges *mockCharges charges *mockCharges
promoCodes *mockPromoCodes promoCodes *mockPromoCodes
creditNotes *mockCreditNotes
} }
type mockStripeClient struct { type mockStripeClient struct {
@ -164,6 +165,10 @@ func (m *mockStripeClient) PromoCodes() StripePromoCodes {
return m.promoCodes return m.promoCodes
} }
func (m *mockStripeClient) CreditNotes() StripeCreditNotes {
return m.creditNotes
}
type mockCustomers struct { type mockCustomers struct {
customersDB CustomersDB customersDB CustomersDB
usersDB console.Users usersDB console.Users
@ -430,6 +435,10 @@ func (m *mockInvoices) FinalizeInvoice(id string, params *stripe.InvoiceFinalize
return nil, nil return nil, nil
} }
func (m *mockInvoices) Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error) {
return nil, nil
}
type mockInvoiceItems struct { type mockInvoiceItems struct {
} }
@ -534,3 +543,10 @@ func (m *mockPromoCodes) List(params *stripe.PromotionCodeListParams) *promotion
return &promotioncode.Iter{Iter: stripe.GetIter(params, query)} return &promotioncode.Iter{Iter: stripe.GetIter(params, query)}
} }
type mockCreditNotes struct {
}
func (m mockCreditNotes) New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error) {
return nil, nil
}