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:
parent
480715e3b8
commit
4ab8031171
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user