cmd/satellite: combine draft invoice generation commands

This change introduces the generate-invoices satellite billing
command whose functionality is equivalent to running
apply-free-coupons, prepare-invoice-records,
create-project-invoice-items, and create-invoices in order.
Invoice finalization must still be performed separately.

Change-Id: Ia3d80b95eef1f2776c38bd730ed731e42ec4c35e
This commit is contained in:
Jeremy Wharton 2022-09-27 03:48:38 -05:00
parent 480715e3b8
commit 4ab8031171
4 changed files with 207 additions and 6 deletions

View File

@ -220,6 +220,13 @@ var (
Args: cobra.ExactArgs(1),
RunE: cmdCreateCustomerInvoices,
}
generateCustomerInvoicesCmd = &cobra.Command{
Use: "generate-invoices [period]",
Short: "Performs all tasks necessary to generate Stripe invoices",
Long: "Performs all tasks necessary to generate Stripe invoices. Equivalent to running apply-free-coupons, prepare-invoice-records, create-project-invoice-items, and create-invoices in order. Does not finalize invoices.",
Args: cobra.ExactArgs(1),
RunE: cmdGenerateCustomerInvoices,
}
finalizeCustomerInvoicesCmd = &cobra.Command{
Use: "finalize-invoices",
Short: "Finalizes all draft stripe invoices",
@ -346,6 +353,7 @@ func init() {
billingCmd.AddCommand(prepareCustomerInvoiceRecordsCmd)
billingCmd.AddCommand(createCustomerProjectInvoiceItemsCmd)
billingCmd.AddCommand(createCustomerInvoicesCmd)
billingCmd.AddCommand(generateCustomerInvoicesCmd)
billingCmd.AddCommand(finalizeCustomerInvoicesCmd)
billingCmd.AddCommand(payCustomerInvoicesCmd)
billingCmd.AddCommand(stripeCustomerCmd)
@ -373,6 +381,7 @@ func init() {
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))
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(stripeCustomerCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
@ -760,6 +769,19 @@ func cmdCreateCustomerInvoices(cmd *cobra.Command, args []string) (err error) {
})
}
func cmdGenerateCustomerInvoices(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)
period, err := parseBillingPeriod(args[0])
if err != nil {
return errs.New("invalid period specified: %v", err)
}
return runBillingCmd(ctx, func(ctx context.Context, payments *stripecoinpayments.Service, _ satellite.DB) error {
return payments.GenerateInvoices(ctx, period)
})
}
func cmdFinalizeCustomerInvoices(cmd *cobra.Command, args []string) (err error) {
ctx, _ := process.Ctx(cmd)

View File

@ -929,6 +929,32 @@ func (service *Service) createInvoice(ctx context.Context, cusID string, period
return nil
}
// GenerateInvoices performs all tasks necessary to generate Stripe invoices.
// This is equivalent to invoking ApplyFreeTierCoupons, PrepareInvoiceProjectRecords,
// InvoiceApplyProjectRecords, and CreateInvoices in order.
func (service *Service) GenerateInvoices(ctx context.Context, period time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
for _, subFn := range []struct {
Description string
Exec func(context.Context, time.Time) error
}{
{"Applying free tier coupons", func(ctx context.Context, _ time.Time) error {
return service.ApplyFreeTierCoupons(ctx)
}},
{"Preparing invoice project records", service.PrepareInvoiceProjectRecords},
{"Applying invoice project records", service.InvoiceApplyProjectRecords},
{"Creating invoices", service.CreateInvoices},
} {
service.log.Info(subFn.Description)
if err := subFn.Exec(ctx, period); err != nil {
return err
}
}
return nil
}
// 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)

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v72"
"go.uber.org/zap"
"storj.io/common/currency"
@ -342,3 +343,68 @@ func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
require.NoError(t, err)
})
}
func TestService_GenerateInvoice(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]
payments := satellite.API.Payments
// pick a specific date so that it doesn't fail if it's the last day of the month
// keep month + 1 because user needs to be created before calculation
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
payments.StripeService.SetNow(func() time.Time {
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
})
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
user, err := satellite.AddUser(ctx, console.CreateUser{
FullName: "Test User",
Email: "test@mail.test",
}, 1)
require.NoError(t, err)
proj, err := satellite.AddProject(ctx, user.ID, "testproject")
require.NoError(t, err)
require.NoError(t, payments.StripeService.GenerateInvoices(ctx, start))
// ensure free tier coupon was applied
cusID, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID)
require.NoError(t, err)
stripeUser, err := payments.StripeClient.Customers().Get(cusID, nil)
require.NoError(t, err)
require.NotNil(t, stripeUser.Discount)
require.NotNil(t, stripeUser.Discount.Coupon)
require.Equal(t, payments.StripeService.StripeFreeTierCouponID, stripeUser.Discount.Coupon.ID)
// ensure project record was generated
err = satellite.DB.StripeCoinPayments().ProjectRecords().Check(ctx, proj.ID, start, end)
require.ErrorIs(t, stripecoinpayments.ErrProjectRecordExists, err)
rec, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, proj.ID, start, end)
require.NotNil(t, rec)
require.NoError(t, err)
// ensure an invoice was created
invoiceIter := payments.StripeClient.Invoices().List(&stripe.InvoiceListParams{Customer: &cusID})
require.True(t, invoiceIter.Next())
invoice := invoiceIter.Invoice()
// ensure project record was applied as invoice items to that invoice
itemIter := payments.StripeClient.InvoiceItems().List(&stripe.InvoiceItemListParams{Customer: &cusID})
count := 0
for ; itemIter.Next(); count++ {
item := itemIter.InvoiceItem()
require.Contains(t, item.Metadata, "projectID")
require.Equal(t, item.Metadata["projectID"], proj.ID.String())
require.NotNil(t, itemIter.InvoiceItem().Invoice)
require.Equal(t, invoice.ID, itemIter.InvoiceItem().Invoice.ID)
}
require.NotZero(t, count)
})
}

View File

@ -112,14 +112,15 @@ func NewStripeMock(id storj.NodeID, customersDB CustomersDB, usersDB console.Use
state = &mockStripeState{
customers: &mockCustomersState{},
paymentMethods: newMockPaymentMethods(),
invoices: &mockInvoices{},
invoiceItems: &mockInvoiceItems{},
invoices: newMockInvoices(),
invoiceItems: newMockInvoiceItems(),
customerBalanceTransactions: newMockCustomerBalanceTransactions(),
charges: &mockCharges{},
promoCodes: &mockPromoCodes{
promoCodes: testPromoCodes,
},
}
state.invoices.invoiceItems = state.invoiceItems
mocks.m[id] = state
}
@ -302,6 +303,9 @@ func (m *mockCustomers) Update(id string, params *stripe.CustomerParams) (*strip
if params.PromotionCode != nil && promoIDs[*params.PromotionCode] != nil {
customer.Discount = &stripe.Discount{Coupon: promoIDs[*params.PromotionCode].Coupon}
}
if params.Coupon != nil {
customer.Discount = &stripe.Discount{Coupon: &stripe.Coupon{ID: *params.Coupon}}
}
// TODO update customer with more params as necessary
@ -421,14 +425,60 @@ func (m *mockPaymentMethods) Detach(id string, params *stripe.PaymentMethodDetac
}
type mockInvoices struct {
invoices map[string][]*stripe.Invoice
invoiceItems *mockInvoiceItems
}
func newMockInvoices() *mockInvoices {
return &mockInvoices{
invoices: make(map[string][]*stripe.Invoice),
}
}
func (m *mockInvoices) New(params *stripe.InvoiceParams) (*stripe.Invoice, error) {
return nil, nil
mocks.Lock()
defer mocks.Unlock()
invoice := &stripe.Invoice{ID: "in_" + string(testrand.RandAlphaNumeric(25))}
m.invoices[*params.Customer] = append(m.invoices[*params.Customer], invoice)
if items, ok := m.invoiceItems.items[*params.Customer]; ok {
for _, item := range items {
if item.Invoice == nil {
item.Invoice = invoice
}
}
}
return invoice, nil
}
func (m *mockInvoices) List(listParams *stripe.InvoiceListParams) *invoice.Iter {
return &invoice.Iter{Iter: stripe.GetIter(listParams, mockEmptyQuery)}
mocks.Lock()
defer mocks.Unlock()
listMeta := &stripe.ListMeta{
HasMore: false,
TotalCount: uint32(len(m.invoices)),
}
lc := newListContainer(listMeta)
query := stripe.Query(func(*stripe.Params, *form.Values) (ret []interface{}, _ stripe.ListContainer, _ error) {
if listParams.Customer == nil {
for _, invoices := range m.invoices {
for _, invoice := range invoices {
ret = append(ret, invoice)
}
}
} else if list, ok := m.invoices[*listParams.Customer]; ok {
for _, invoice := range list {
ret = append(ret, invoice)
}
}
return ret, lc, nil
})
return &invoice.Iter{Iter: stripe.GetIter(nil, query)}
}
func (m *mockInvoices) FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error) {
@ -440,6 +490,13 @@ func (m *mockInvoices) Pay(id string, params *stripe.InvoicePayParams) (*stripe.
}
type mockInvoiceItems struct {
items map[string][]*stripe.InvoiceItem
}
func newMockInvoiceItems() *mockInvoiceItems {
return &mockInvoiceItems{
items: make(map[string][]*stripe.InvoiceItem),
}
}
func (m *mockInvoiceItems) Update(id string, params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error) {
@ -451,11 +508,41 @@ func (m *mockInvoiceItems) Del(id string, params *stripe.InvoiceItemParams) (*st
}
func (m *mockInvoiceItems) New(params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error) {
return nil, nil
mocks.Lock()
defer mocks.Unlock()
item := &stripe.InvoiceItem{
Metadata: params.Metadata,
}
m.items[*params.Customer] = append(m.items[*params.Customer], item)
return item, nil
}
func (m *mockInvoiceItems) List(listParams *stripe.InvoiceItemListParams) *invoiceitem.Iter {
return &invoiceitem.Iter{Iter: stripe.GetIter(listParams, mockEmptyQuery)}
mocks.Lock()
defer mocks.Unlock()
listMeta := &stripe.ListMeta{
HasMore: false,
TotalCount: uint32(len(m.items)),
}
lc := newListContainer(listMeta)
query := stripe.Query(func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListContainer, error) {
list, ok := m.items[*listParams.Customer]
if !ok {
list = []*stripe.InvoiceItem{}
}
ret := make([]interface{}, len(list))
for i, v := range list {
ret[i] = v
}
return ret, lc, nil
})
return &invoiceitem.Iter{Iter: stripe.GetIter(nil, query)}
}
type mockCustomerBalanceTransactions struct {