satellite/payments/stripe/service: add manual payment with token command
Add the ability to pay an individual users open invoices using their storj token balance. Change-Id: I6115f2b033fd77f109ded6f55b1f35fc77c71ff1
This commit is contained in:
parent
0f4371e84c
commit
c96c83e805
@ -260,12 +260,19 @@ var (
|
||||
Long: "Finalizes all draft stripe invoices known to satellite's stripe account.",
|
||||
RunE: cmdFinalizeCustomerInvoices,
|
||||
}
|
||||
payCustomerInvoicesCmd = &cobra.Command{
|
||||
payInvoicesWithTokenCmd = &cobra.Command{
|
||||
Use: "pay-customer-invoices",
|
||||
Short: "pay open finalized invoices for customer",
|
||||
Long: "attempts payment on any open finalized invoices for a specific user.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: cmdPayCustomerInvoices,
|
||||
}
|
||||
payAllInvoicesCmd = &cobra.Command{
|
||||
Use: "pay-invoices",
|
||||
Short: "pay finalized invoices",
|
||||
Long: "attempts payment on all open finalized invoices according to subscriptions settings.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: cmdPayCustomerInvoices,
|
||||
RunE: cmdPayAllInvoices,
|
||||
}
|
||||
stripeCustomerCmd = &cobra.Command{
|
||||
Use: "ensure-stripe-customer",
|
||||
@ -404,7 +411,8 @@ func init() {
|
||||
billingCmd.AddCommand(createCustomerInvoicesCmd)
|
||||
billingCmd.AddCommand(generateCustomerInvoicesCmd)
|
||||
billingCmd.AddCommand(finalizeCustomerInvoicesCmd)
|
||||
billingCmd.AddCommand(payCustomerInvoicesCmd)
|
||||
billingCmd.AddCommand(payInvoicesWithTokenCmd)
|
||||
billingCmd.AddCommand(payAllInvoicesCmd)
|
||||
billingCmd.AddCommand(stripeCustomerCmd)
|
||||
consistencyCmd.AddCommand(consistencyGECleanupCmd)
|
||||
process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
|
||||
@ -439,7 +447,8 @@ func init() {
|
||||
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(payInvoicesWithTokenCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
|
||||
process.Bind(payAllInvoicesCmd, &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(fixLastNetsCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.IdentityDir(identityDir))
|
||||
@ -869,6 +878,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 *stripe.Service, _ satellite.DB) error {
|
||||
err := payments.InvoiceApplyCustomerTokenBalance(ctx, args[0])
|
||||
if err != nil {
|
||||
return errs.New("error applying native token payments to invoice for customer: %v", err)
|
||||
}
|
||||
return payments.PayCustomerInvoices(ctx, args[0])
|
||||
})
|
||||
}
|
||||
|
||||
func cmdPayAllInvoices(cmd *cobra.Command, args []string) (err error) {
|
||||
ctx, _ := process.Ctx(cmd)
|
||||
|
||||
periodStart, err := parseYearMonth(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -288,78 +288,42 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context, createdOnA
|
||||
|
||||
for _, wallet := range wallets {
|
||||
// get the stripe customer invoice balance
|
||||
cusID, err := service.db.Customers().GetCustomerID(ctx, wallet.UserID)
|
||||
customerID, err := service.db.Customers().GetCustomerID(ctx, wallet.UserID)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to get stripe customer ID for user ID %s", wallet.UserID.String()))
|
||||
continue
|
||||
}
|
||||
invoices, err := service.getInvoices(ctx, cusID, createdOnAfter)
|
||||
customerInvoices, err := service.getInvoices(ctx, customerID, createdOnAfter)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to get invoice balance for stripe customer ID %s", cusID))
|
||||
errGrp.Add(Error.New("unable to get invoice balance for stripe customer ID %s", customerID))
|
||||
continue
|
||||
}
|
||||
for _, invoice := range invoices {
|
||||
// if no balance due, do nothing
|
||||
if invoice.AmountRemaining <= 0 {
|
||||
continue
|
||||
}
|
||||
monetaryTokenBalance, err := service.billingDB.GetBalance(ctx, wallet.UserID)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to get balance for user ID %s", wallet.UserID.String()))
|
||||
continue
|
||||
}
|
||||
// truncate here since stripe only has cent level precision for invoices.
|
||||
// The users account balance will still maintain the full precision monetary value!
|
||||
tokenBalance := currency.AmountFromDecimal(monetaryTokenBalance.AsDecimal().Truncate(2), currency.USDollars)
|
||||
// if token balance is not > 0, don't bother with the rest
|
||||
if tokenBalance.BaseUnits() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var tokenCreditAmount int64
|
||||
if invoice.AmountRemaining >= tokenBalance.BaseUnits() {
|
||||
tokenCreditAmount = tokenBalance.BaseUnits()
|
||||
} else {
|
||||
tokenCreditAmount = invoice.AmountRemaining
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 credit note for user %s", wallet.UserID.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(map[string]interface{}{
|
||||
"Credit Note ID": creditNoteID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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 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
|
||||
}
|
||||
err = service.payInvoicesWithTokenBalance(ctx, customerID, wallet, customerInvoices)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to pay invoices for stripe customer ID %s", customerID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return errGrp.Err()
|
||||
}
|
||||
|
||||
// InvoiceApplyCustomerTokenBalance creates invoice credit notes for the customers token payments to open invoices.
|
||||
func (service *Service) InvoiceApplyCustomerTokenBalance(ctx context.Context, customerID string) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
userID, err := service.db.Customers().GetUserID(ctx, customerID)
|
||||
if err != nil {
|
||||
return Error.New("unable to get user ID for stripe customer ID %s", customerID)
|
||||
}
|
||||
|
||||
customerInvoices, err := service.getInvoices(ctx, customerID, time.Unix(0, 0))
|
||||
if err != nil {
|
||||
return Error.New("error getting invoices for stripe customer %s", customerID)
|
||||
}
|
||||
|
||||
return service.PayInvoicesWithTokenBalance(ctx, userID, customerID, customerInvoices)
|
||||
}
|
||||
|
||||
// getInvoices returns the stripe customer's open finalized invoices created on or after the given date.
|
||||
func (service *Service) getInvoices(ctx context.Context, cusID string, createdOnAfter time.Time) (_ []stripe.Invoice, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
@ -378,6 +342,9 @@ func (service *Service) getInvoices(ctx context.Context, cusID string, createdOn
|
||||
stripeInvoices = append(stripeInvoices, *stripeInvoice)
|
||||
}
|
||||
}
|
||||
if err = invoicesIterator.Err(); err != nil {
|
||||
return stripeInvoices, Error.Wrap(err)
|
||||
}
|
||||
return stripeInvoices, nil
|
||||
}
|
||||
|
||||
@ -1091,6 +1058,119 @@ func (service *Service) PayInvoices(ctx context.Context, createdOnAfter time.Tim
|
||||
return invoicesIterator.Err()
|
||||
}
|
||||
|
||||
// PayCustomerInvoices attempts to transition all open finalized invoices created on or after a certain time to "paid"
|
||||
// by charging the customer according to subscriptions settings.
|
||||
func (service *Service) PayCustomerInvoices(ctx context.Context, customerID string) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
customerInvoices, err := service.getInvoices(ctx, customerID, time.Unix(0, 0))
|
||||
if err != nil {
|
||||
return Error.New("error getting invoices for stripe customer %s", customerID)
|
||||
}
|
||||
|
||||
var errGrp errs.Group
|
||||
for _, customerInvoice := range customerInvoices {
|
||||
if customerInvoice.DueDate > 0 {
|
||||
service.log.Info("Skipping invoice marked for manual payment",
|
||||
zap.String("id", customerInvoice.ID),
|
||||
zap.String("number", customerInvoice.Number),
|
||||
zap.String("customer", customerInvoice.Customer.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
params := &stripe.InvoicePayParams{Params: stripe.Params{Context: ctx}}
|
||||
_, err = service.stripeClient.Invoices().Pay(customerInvoice.ID, params)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to pay invoice %s", customerInvoice.ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return errGrp.Err()
|
||||
}
|
||||
|
||||
// PayInvoicesWithTokenBalance attempts to transition all the users open invoices to "paid" by charging the customer
|
||||
// token balance.
|
||||
func (service *Service) PayInvoicesWithTokenBalance(ctx context.Context, userID uuid.UUID, cusID string, invoices []stripe.Invoice) (err error) {
|
||||
// get wallet
|
||||
wallet, err := service.walletsDB.GetWallet(ctx, userID)
|
||||
if err != nil {
|
||||
return Error.New("unable to get users in the wallets table")
|
||||
}
|
||||
|
||||
return service.payInvoicesWithTokenBalance(ctx, cusID, storjscan.Wallet{
|
||||
UserID: userID,
|
||||
Address: wallet,
|
||||
}, invoices)
|
||||
}
|
||||
|
||||
// payInvoicesWithTokenBalance attempts to transition the users open invoices to "paid" by charging the customer
|
||||
// token balance.
|
||||
func (service *Service) payInvoicesWithTokenBalance(ctx context.Context, cusID string, wallet storjscan.Wallet, invoices []stripe.Invoice) (err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var errGrp errs.Group
|
||||
|
||||
for _, invoice := range invoices {
|
||||
// if no balance due, do nothing
|
||||
if invoice.AmountRemaining <= 0 {
|
||||
continue
|
||||
}
|
||||
monetaryTokenBalance, err := service.billingDB.GetBalance(ctx, wallet.UserID)
|
||||
if err != nil {
|
||||
errGrp.Add(Error.New("unable to get balance for user ID %s", wallet.UserID.String()))
|
||||
continue
|
||||
}
|
||||
// truncate here since stripe only has cent level precision for invoices.
|
||||
// The users account balance will still maintain the full precision monetary value!
|
||||
tokenBalance := currency.AmountFromDecimal(monetaryTokenBalance.AsDecimal().Truncate(2), currency.USDollars)
|
||||
// if token balance is not > 0, don't bother with the rest
|
||||
if tokenBalance.BaseUnits() <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
var tokenCreditAmount int64
|
||||
if invoice.AmountRemaining >= tokenBalance.BaseUnits() {
|
||||
tokenCreditAmount = tokenBalance.BaseUnits()
|
||||
} else {
|
||||
tokenCreditAmount = invoice.AmountRemaining
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 credit note for user %s", wallet.UserID.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(map[string]interface{}{
|
||||
"Credit Note ID": creditNoteID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
return errGrp.Err()
|
||||
}
|
||||
|
||||
// projectUsagePrice represents pricing for project usage.
|
||||
type projectUsagePrice struct {
|
||||
Storage decimal.Decimal
|
||||
|
@ -929,6 +929,154 @@ func TestService_PayMultipleInvoiceFromTokenBalance(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_PayMultipleInvoiceForCustomer(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]
|
||||
|
||||
// create user
|
||||
user, err := satellite.AddUser(ctx, console.CreateUser{
|
||||
FullName: "testuser",
|
||||
Email: "user@test",
|
||||
}, 1)
|
||||
require.NoError(t, err)
|
||||
customer, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
amount1 := int64(75)
|
||||
amount2 := int64(100)
|
||||
curr := string(stripe.CurrencyUSD)
|
||||
|
||||
// create invoice items for first invoice
|
||||
inv1Item1, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Amount: &amount1,
|
||||
Currency: &curr,
|
||||
Customer: &customer,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
inv1Item2, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Amount: &amount1,
|
||||
Currency: &curr,
|
||||
Customer: &customer,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
Inv1Items := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 2)
|
||||
Inv1Items = append(Inv1Items, &stripe.InvoiceUpcomingInvoiceItemParams{
|
||||
InvoiceItem: &inv1Item1.ID,
|
||||
Amount: &amount1,
|
||||
Currency: &curr,
|
||||
})
|
||||
Inv1Items = append(Inv1Items, &stripe.InvoiceUpcomingInvoiceItemParams{
|
||||
InvoiceItem: &inv1Item2.ID,
|
||||
Amount: &amount1,
|
||||
Currency: &curr,
|
||||
})
|
||||
|
||||
// invoice items for second invoice
|
||||
inv2Item1, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Amount: &amount2,
|
||||
Currency: &curr,
|
||||
Customer: &customer,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
inv2Item2, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Amount: &amount2,
|
||||
Currency: &curr,
|
||||
Customer: &customer,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
Inv2Items := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 2)
|
||||
Inv2Items = append(Inv2Items, &stripe.InvoiceUpcomingInvoiceItemParams{
|
||||
InvoiceItem: &inv2Item1.ID,
|
||||
Amount: &amount2,
|
||||
Currency: &curr,
|
||||
})
|
||||
Inv2Items = append(Inv2Items, &stripe.InvoiceUpcomingInvoiceItemParams{
|
||||
InvoiceItem: &inv2Item2.ID,
|
||||
Amount: &amount2,
|
||||
Currency: &curr,
|
||||
})
|
||||
|
||||
// create invoice one
|
||||
inv1, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Customer: &customer,
|
||||
InvoiceItems: Inv1Items,
|
||||
DefaultPaymentMethod: stripe.String(stripe1.MockInvoicesPaySuccess),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create invoice two
|
||||
inv2, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{
|
||||
Params: stripe.Params{Context: ctx},
|
||||
Customer: &customer,
|
||||
InvoiceItems: Inv2Items,
|
||||
DefaultPaymentMethod: stripe.String(stripe1.MockInvoicesPaySuccess),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
finalizeParams := &stripe.InvoiceFinalizeParams{Params: stripe.Params{Context: ctx}}
|
||||
|
||||
// finalize invoice one
|
||||
inv1, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv1.ID, finalizeParams)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, stripe.InvoiceStatusOpen, inv1.Status)
|
||||
|
||||
// finalize invoice two
|
||||
inv2, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv2.ID, finalizeParams)
|
||||
inv2.Metadata = map[string]string{"PaymentMethod": stripe1.MockInvoicesPaySuccess}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, stripe.InvoiceStatusOpen, inv2.Status)
|
||||
|
||||
// setup storjscan wallet and user balance
|
||||
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
|
||||
require.NoError(t, err)
|
||||
userID := user.ID
|
||||
err = satellite.DB.Wallets().Add(ctx, userID, address)
|
||||
require.NoError(t, err)
|
||||
// User balance is not enough to cover full amount of both invoices
|
||||
_, err = satellite.DB.Billing().Insert(ctx, billing.Transaction{
|
||||
UserID: userID,
|
||||
Amount: currency.AmountFromBaseUnits(300, currency.USDollars),
|
||||
Description: "token payment credit",
|
||||
Source: billing.StorjScanSource,
|
||||
Status: billing.TransactionStatusCompleted,
|
||||
Type: billing.TransactionTypeCredit,
|
||||
Metadata: nil,
|
||||
Timestamp: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// attempt to pay user invoices, CC should be used to cover remainder after token balance
|
||||
err = satellite.API.Payments.StripeService.InvoiceApplyCustomerTokenBalance(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
err = satellite.API.Payments.StripeService.PayCustomerInvoices(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
|
||||
iter := satellite.API.Payments.StripeClient.Invoices().List(&stripe.InvoiceListParams{
|
||||
ListParams: stripe.ListParams{Context: ctx},
|
||||
})
|
||||
var stripeInvoices []*stripe.Invoice
|
||||
for iter.Next() {
|
||||
stripeInvoices = append(stripeInvoices, iter.Invoice())
|
||||
}
|
||||
require.Equal(t, 2, len(stripeInvoices))
|
||||
require.Equal(t, stripe.InvoiceStatusPaid, stripeInvoices[0].Status)
|
||||
require.Equal(t, stripe.InvoiceStatusPaid, stripeInvoices[1].Status)
|
||||
require.NoError(t, iter.Err())
|
||||
balance, err := satellite.DB.Billing().GetBalance(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, balance.IsNegative())
|
||||
require.Zero(t, balance.BaseUnits())
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GenerateInvoice(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
desc string
|
||||
|
Loading…
Reference in New Issue
Block a user