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:
parent
8beb78ec3f
commit
cf5d2d792c
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user