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),
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)

View File

@ -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{

View File

@ -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

View File

@ -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
}