satellite/payments/stripe/service.go: fix payment for multiple invoices

Fix an error that can occur when processing multiple invoices for the same user in a single invoice cycle when the user is paying with Storj tokens.

Change-Id: I54af8c7dde1965d994f687fdfc4e4b5ef4deeb2d
This commit is contained in:
dlamarmorgan 2023-04-11 15:22:45 -07:00 committed by Damein Morgan
parent 8beb78ec3f
commit cf5d2d792c
6 changed files with 268 additions and 21 deletions

View File

@ -1683,7 +1683,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
require.Len(t, invs, 1)
require.Equal(t, draftInv, invs[0].ID)
require.NoError(t, p.Purchase(userCtx, 1000, draftInvDesc, testPaymentMethod))
require.NoError(t, p.Purchase(userCtx, 1000, draftInvDesc, stripe.MockInvoicesPaySuccess))
invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
require.NoError(t, err)
@ -1714,7 +1714,7 @@ func TestPaymentsPurchasePreexistingInvoice(t *testing.T) {
}
require.True(t, foundInv)
require.NoError(t, p.Purchase(userCtx, 1000, openInvDesc, testPaymentMethod))
require.NoError(t, p.Purchase(userCtx, 1000, openInvDesc, stripe.MockInvoicesPaySuccess))
invs, err = sat.API.Payments.StripeService.Accounts().Invoices().List(ctx, user.ID)
require.NoError(t, err)

View File

@ -74,8 +74,10 @@ func TestAutoFreezeChore(t *testing.T) {
})
require.NoError(t, err)
paymentMethod := stripe1.MockInvoicesPaySuccess
inv, err = stripeClient.Invoices().Pay(inv.ID, &stripe.InvoicePayParams{
Params: stripe.Params{Context: ctx},
Params: stripe.Params{Context: ctx},
PaymentMethod: &paymentMethod,
})
require.NoError(t, err)
require.Equal(t, stripe.InvoiceStatusPaid, inv.Status)

View File

@ -44,7 +44,7 @@ func TestInvoices(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, pi)
confirmedPI, err := satellite.API.Payments.Accounts.Invoices().Pay(ctx, pi.ID, "test_payment_method")
confirmedPI, err := satellite.API.Payments.Accounts.Invoices().Pay(ctx, pi.ID, stripe1.MockInvoicesPaySuccess)
require.NoError(t, err)
require.Equal(t, pi.ID, confirmedPI.ID)
require.Equal(t, string(stripe.InvoiceStatusPaid), confirmedPI.Status)

View File

@ -280,18 +280,6 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context, createdOnA
var errGrp errs.Group
for _, wallet := range wallets {
// get the user token balance, if it's not > 0, don't bother with the rest
monetaryTokenBalance, err := service.billingDB.GetBalance(ctx, wallet.UserID)
// 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 err != nil {
errGrp.Add(Error.New("unable to compute balance for user ID %s", wallet.UserID.String()))
continue
}
if tokenBalance.BaseUnits() <= 0 {
continue
}
// get the stripe customer invoice balance
cusID, err := service.db.Customers().GetCustomerID(ctx, wallet.UserID)
if err != nil {
@ -308,6 +296,18 @@ func (service *Service) InvoiceApplyTokenBalance(ctx context.Context, createdOnA
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() {
@ -825,6 +825,10 @@ func (service *Service) CreateBalanceInvoiceItems(ctx context.Context) (err erro
}
service.log.Info("Customer successfully updated", zap.String("CustomerID", itr.Customer().ID), zap.Int64("Prior Balance", itr.Customer().Balance), zap.Int64("New Balance", 0), zap.String("InvoiceItemID", invoiceItem.ID))
}
if itr.Err() != nil {
service.log.Error("Failed to create invoice items for all customers", zap.Error(itr.Err()))
errGrp.Add(itr.Err())
}
return errGrp.Err()
}

View File

@ -400,7 +400,7 @@ func TestService_InvoiceItemsFromProjectUsage(t *testing.T) {
})
}
func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
func TestService_PayInvoiceFromTokenBalance(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
@ -412,11 +412,48 @@ func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
satellite := planet.Satellites[0]
payments := satellite.API.Payments
tokenBalance := currency.AmountFromBaseUnits(1000, currency.USDollars)
invoiceBalance := currency.AmountFromBaseUnits(800, currency.USDollars)
usdCurrency := string(stripe.CurrencyUSD)
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)
// create invoice item
invItem, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
Params: stripe.Params{Context: ctx},
Amount: stripe.Int64(invoiceBalance.BaseUnits()),
Currency: stripe.String(usdCurrency),
Customer: &customer,
})
require.NoError(t, err)
InvItems := make([]*stripe.InvoiceUpcomingInvoiceItemParams, 0, 1)
InvItems = append(InvItems, &stripe.InvoiceUpcomingInvoiceItemParams{
InvoiceItem: &invItem.ID,
Amount: &invItem.Amount,
Currency: stripe.String(usdCurrency),
})
// create invoice
inv, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{
Params: stripe.Params{Context: ctx},
Customer: &customer,
InvoiceItems: InvItems,
})
require.NoError(t, err)
finalizeParams := &stripe.InvoiceFinalizeParams{Params: stripe.Params{Context: ctx}}
// finalize invoice
inv, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv.ID, finalizeParams)
require.NoError(t, err)
require.Equal(t, stripe.InvoiceStatusOpen, inv.Status)
// setup storjscan wallet
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
@ -426,7 +463,7 @@ func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
require.NoError(t, err)
_, err = satellite.DB.Billing().Insert(ctx, billing.Transaction{
UserID: userID,
Amount: currency.AmountFromBaseUnits(1000, currency.USDollars),
Amount: tokenBalance,
Description: "token payment credit",
Source: billing.StorjScanSource,
Status: billing.TransactionStatusCompleted,
@ -440,6 +477,168 @@ func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
// run apply token balance to see if there are no unexpected errors
err = payments.StripeService.InvoiceApplyTokenBalance(ctx, time.Time{})
require.NoError(t, err)
err = satellite.API.Payments.StripeService.PayInvoices(ctx, time.Time{})
require.NoError(t, err)
iter := satellite.API.Payments.StripeClient.Invoices().List(&stripe.InvoiceListParams{
ListParams: stripe.ListParams{Context: ctx},
})
iter.Next()
require.Equal(t, stripe.InvoiceStatusPaid, iter.Invoice().Status)
// balance is in USDollars Micro, so it needs to be converted before comparison
balance, err := satellite.DB.Billing().GetBalance(ctx, userID)
balance = currency.AmountFromDecimal(balance.AsDecimal().Truncate(2), currency.USDollars)
require.NoError(t, err)
require.Equal(t, tokenBalance.BaseUnits()-invoiceBalance.BaseUnits(), balance.BaseUnits())
})
}
func TestService_PayMultipleInvoiceFromTokenBalance(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,
})
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,
})
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)
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 apply token balance to invoices
err = satellite.API.Payments.StripeService.InvoiceApplyTokenBalance(ctx, time.Time{})
require.NoError(t, err)
err = satellite.API.Payments.StripeService.PayInvoices(ctx, time.Time{})
require.NoError(t, err)
iter := satellite.API.Payments.StripeClient.Invoices().List(&stripe.InvoiceListParams{
ListParams: stripe.ListParams{Context: ctx},
})
for iter.Next() {
if iter.Invoice().AmountRemaining == 0 {
require.Equal(t, stripe.InvoiceStatusPaid, iter.Invoice().Status)
} else {
require.Equal(t, stripe.InvoiceStatusOpen, iter.Invoice().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())
})
}

View File

@ -41,6 +41,10 @@ const (
// an error.
MockInvoicesPayFailure = "mock_invoices_pay_failure"
// MockInvoicesPaySuccess can be passed to mockInvoices.Pay as params.PaymentMethod to cause it to return
// a paid invoice.
MockInvoicesPaySuccess = "mock_invoices_pay_success"
// TestPaymentMethodsNewFailure can be passed to creditCards.Add as the cardToken arg to cause
// mockPaymentMethods.New to return an error.
TestPaymentMethodsNewFailure = "test_payment_methods_new_failure"
@ -137,6 +141,7 @@ func NewStripeMock(customersDB CustomersDB, usersDB console.Users) Client {
state.customerBalanceTransactions = newMockCustomerBalanceTransactions(state)
state.charges = &mockCharges{}
state.promoCodes = newMockPromoCodes(state)
state.creditNotes = newMockCreditNotes(state)
return &mockStripeClient{
customersDB: customersDB,
@ -516,12 +521,14 @@ func (m *mockInvoices) New(params *stripe.InvoiceParams) (*stripe.Invoice, error
due = *params.DueDate
}
amountDue := int64(0)
lineData := make([]*stripe.InvoiceLine, 0, len(params.InvoiceItems))
for _, item := range params.InvoiceItems {
lineData = append(lineData, &stripe.InvoiceLine{
InvoiceItem: *item.InvoiceItem,
Amount: *item.Amount,
})
amountDue += *item.Amount
}
var desc string
@ -541,6 +548,8 @@ func (m *mockInvoices) New(params *stripe.InvoiceParams) (*stripe.Invoice, error
Lines: &stripe.InvoiceLineList{
Data: lineData,
},
AmountDue: amountDue,
AmountRemaining: amountDue,
}
m.invoices[*params.Customer] = append(m.invoices[*params.Customer], invoice)
@ -618,15 +627,21 @@ func (m *mockInvoices) FinalizeInvoice(id string, params *stripe.InvoiceFinalize
func (m *mockInvoices) Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error) {
for _, invoices := range m.invoices {
for i, invoice := range invoices {
for _, invoice := range invoices {
if invoice.ID == id {
if params.PaymentMethod != nil {
if *params.PaymentMethod == MockInvoicesPayFailure {
invoice.Status = stripe.InvoiceStatusOpen
return invoice, &stripe.Error{}
}
if *params.PaymentMethod == MockInvoicesPaySuccess {
invoice.Status = stripe.InvoiceStatusPaid
invoice.AmountRemaining = 0
return invoice, nil
}
} else if invoice.AmountRemaining == 0 {
invoice.Status = stripe.InvoiceStatusPaid
}
m.invoices[invoice.Customer.ID][i].Status = stripe.InvoiceStatusPaid
return invoice, nil
}
}
@ -825,8 +840,35 @@ func (m *mockPromoCodes) List(params *stripe.PromotionCodeListParams) *promotion
}
type mockCreditNotes struct {
root *mockStripeState
CreditNotes map[string]*stripe.CreditNote
}
func newMockCreditNotes(root *mockStripeState) *mockCreditNotes {
return &mockCreditNotes{
root: root,
}
}
func (m mockCreditNotes) New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error) {
return nil, nil
m.root.mu.Lock()
defer m.root.mu.Unlock()
item := &stripe.CreditNote{}
if params.Invoice != nil {
item.ID = *params.Invoice
}
if params.Memo != nil {
item.Memo = *params.Memo
}
for _, invoices := range m.root.invoices.invoices {
for _, invoice := range invoices {
if invoice.ID == *params.Invoice {
invoice.AmountRemaining -= *params.Lines[0].UnitAmount
}
}
}
return item, nil
}