storj/satellite/payments/billing/transactions_test.go
dlamarmorgan 8a1bedd367 satellite/payments/{billing,stripe}: handle pending invoice payments
Currently, pending invoice payments that are made using a users token
balance can get stuck in a pending state if the invoice is not able
to be paid appropriately in stripe. This change addresses these stuck
token invoice payments by attempting to transition them to failed
if the invoice cannot be paid.

Change-Id: I2b70a11c97ae5c733d05c918a1082e85bb7f73f3
2023-10-03 16:12:39 +00:00

428 lines
14 KiB
Go

// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package billing_test
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"storj.io/common/currency"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/storj/private/blockchain"
"storj.io/storj/satellite"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/billing"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
)
func TestTransactionsDBList(t *testing.T) {
const (
limit = 3
transactionCount = limit * 4
)
// create transactions
userID := testrand.UUID()
firstTimestamp := makeTimestamp()
var txs []billing.Transaction
var txStatus billing.TransactionStatus
var txType billing.TransactionType
for i := 0; i < transactionCount; i++ {
txSource := "storjscan"
txStatus = billing.TransactionStatusCompleted
txType = billing.TransactionTypeCredit
if i%2 == 0 {
txSource = "stripe"
}
if i%3 == 0 {
txSource = "coinpayments"
}
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
metadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some stripe invoice ID",
"Wallet": address.Hex(),
})
require.NoError(t, err)
createTX := billing.Transaction{
UserID: userID,
Amount: currency.AmountFromBaseUnits(4, currency.USDollars),
Description: "credit from storjscan payment",
Source: txSource,
Status: txStatus,
Type: txType,
Metadata: metadata,
Timestamp: firstTimestamp.Add(time.Duration(i) * time.Second),
}
txs = append(txs, createTX)
}
t.Run("insert and list transactions", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
for _, tx := range txs {
_, err := db.Billing().Insert(ctx, tx)
require.NoError(t, err)
}
actual, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
require.Equal(t, len(txs), len(actual))
// The listing is in descending insertion order so compare
// accordingly (first listed compared with last inserted, etc.)
for i, act := range actual {
exp := txs[len(txs)-i-1]
compareTransactions(t, exp, act)
}
})
})
}
func TestTransactionsDBBalance(t *testing.T) {
tenUSD := currency.AmountFromBaseUnits(1000, currency.USDollars)
tenMicroUSD := currency.AmountFromBaseUnits(10000000, currency.USDollarsMicro)
twentyMicroUSD := currency.AmountFromBaseUnits(20000000, currency.USDollarsMicro)
thirtyUSD := currency.AmountFromBaseUnits(3000, currency.USDollars)
fortyMicroUSD := currency.AmountFromBaseUnits(40000000, currency.USDollarsMicro)
negativeTwentyUSD := currency.AmountFromBaseUnits(-2000, currency.USDollars)
userID := testrand.UUID()
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
creditMetadata, err := json.Marshal(map[string]interface{}{
"Wallet": address.Hex(),
})
require.NoError(t, err)
debitMetadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some stripe invoice ID",
})
require.NoError(t, err)
credit10TX := billing.Transaction{
UserID: userID,
Amount: tenUSD,
Description: "credit from storjscan payment",
Source: "storjscan",
Status: billing.TransactionStatusCompleted,
Type: billing.TransactionTypeCredit,
Metadata: creditMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
credit30TX := billing.Transaction{
UserID: userID,
Amount: thirtyUSD,
Description: "credit from storjscan payment",
Source: "storjscan",
Status: billing.TransactionStatusCompleted,
Type: billing.TransactionTypeCredit,
Metadata: creditMetadata,
Timestamp: makeTimestamp().Add(time.Second * 2),
}
charge20TX := billing.Transaction{
UserID: userID,
Amount: negativeTwentyUSD,
Description: "charge for storage and bandwidth",
Source: "storjscan",
Status: billing.TransactionStatusCompleted,
Type: billing.TransactionTypeDebit,
Metadata: debitMetadata,
Timestamp: makeTimestamp().Add(time.Second * 3),
}
t.Run("add 10 USD to account", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
txs, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
require.Len(t, txs, 1)
compareTransactions(t, credit10TX, txs[0])
balance, err := db.Billing().GetBalance(ctx, userID)
require.NoError(t, err)
require.Equal(t, tenMicroUSD.BaseUnits(), balance.BaseUnits())
})
})
t.Run("add 10 and 30 USD to account", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
_, err = db.Billing().Insert(ctx, credit30TX)
require.NoError(t, err)
txs, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
require.Len(t, txs, 2)
compareTransactions(t, credit30TX, txs[0])
compareTransactions(t, credit10TX, txs[1])
balance, err := db.Billing().GetBalance(ctx, userID)
require.NoError(t, err)
require.Equal(t, fortyMicroUSD.BaseUnits(), balance.BaseUnits())
})
})
t.Run("add 10 USD, add 30 USD, subtract 20 USD", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
_, err = db.Billing().Insert(ctx, credit30TX)
require.NoError(t, err)
_, err = db.Billing().Insert(ctx, charge20TX)
require.NoError(t, err)
txs, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
require.Len(t, txs, 3)
compareTransactions(t, charge20TX, txs[0])
compareTransactions(t, credit30TX, txs[1])
compareTransactions(t, credit10TX, txs[2])
balance, err := db.Billing().GetBalance(ctx, userID)
require.NoError(t, err)
require.Equal(t, twentyMicroUSD.BaseUnits(), balance.BaseUnits())
})
})
}
func TestUpdateTransactions(t *testing.T) {
tenUSD := currency.AmountFromBaseUnits(1000, currency.USDollars)
minusTenUSD := currency.AmountFromBaseUnits(-1000, currency.USDollars)
userID := testrand.UUID()
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
creditMetadata, err := json.Marshal(map[string]interface{}{
"Wallet": address.Hex(),
})
require.NoError(t, err)
debitMetadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some stripe invoice ID",
})
require.NoError(t, err)
credit10TX := billing.Transaction{
UserID: userID,
Amount: tenUSD,
Description: "credit from storjscan payment",
Source: billing.StorjScanSource,
Status: payments.PaymentStatusConfirmed,
Type: billing.TransactionTypeCredit,
Metadata: creditMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
debit10TX := billing.Transaction{
UserID: userID,
Amount: minusTenUSD,
Description: "Paid Stripe Invoice",
Source: billing.StripeSource,
Status: billing.TransactionStatusPending,
Type: billing.TransactionTypeDebit,
Metadata: debitMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
t.Run("update metadata", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
txIDs, err := db.Billing().Insert(ctx, debit10TX)
require.NoError(t, err)
metadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some other stripe invoice ID",
})
require.NoError(t, err)
err = db.Billing().UpdateMetadata(ctx, txIDs[0], metadata)
require.NoError(t, err)
expMetadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some other stripe invoice ID",
})
require.NoError(t, err)
debit10TX.Metadata = expMetadata
tx, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
assert.Equal(t, 2, compareMultipleTransactions(t,
[]billing.Transaction{credit10TX, debit10TX},
tx))
})
})
t.Run("confirm new token deposit", func(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
credit10TX.Status = payments.PaymentStatusConfirmed
tx, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
compareTransactions(t, credit10TX, tx[0])
})
})
}
func TestCompletePendingPayment(t *testing.T) {
tenUSD := currency.AmountFromBaseUnits(1000, currency.USDollars)
minusTenUSD := currency.AmountFromBaseUnits(-1000, currency.USDollars)
userID := testrand.UUID()
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
creditMetadata, err := json.Marshal(map[string]interface{}{
"Wallet": address.Hex(),
})
require.NoError(t, err)
debitMetadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some stripe invoice ID",
})
require.NoError(t, err)
credit10TX := billing.Transaction{
UserID: userID,
Amount: tenUSD,
Description: "credit from storjscan payment",
Source: billing.StorjScanSource,
Status: payments.PaymentStatusConfirmed,
Type: billing.TransactionTypeCredit,
Metadata: creditMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
debit10TX := billing.Transaction{
UserID: userID,
Amount: minusTenUSD,
Description: "Paid Stripe Invoice",
Source: billing.StripeSource,
Status: billing.TransactionStatusPending,
Type: billing.TransactionTypeDebit,
Metadata: debitMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
credit10TX.Status = payments.PaymentStatusConfirmed
tx, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
compareTransactions(t, credit10TX, tx[0])
txIDs, err := db.Billing().Insert(ctx, debit10TX)
require.NoError(t, err)
err = db.Billing().CompletePendingInvoiceTokenPayments(ctx, txIDs[0])
require.NoError(t, err)
debit10TX.Status = billing.TransactionStatusCompleted
tx, err = db.Billing().List(ctx, userID)
require.NoError(t, err)
assert.Equal(t, 2, compareMultipleTransactions(t,
[]billing.Transaction{credit10TX, debit10TX}, tx))
})
}
func TestFailPendingPayment(t *testing.T) {
tenUSD := currency.AmountFromBaseUnits(1000, currency.USDollars)
minusTenUSD := currency.AmountFromBaseUnits(-1000, currency.USDollars)
userID := testrand.UUID()
address, err := blockchain.BytesToAddress(testrand.Bytes(20))
require.NoError(t, err)
creditMetadata, err := json.Marshal(map[string]interface{}{
"Wallet": address.Hex(),
})
require.NoError(t, err)
debitMetadata, err := json.Marshal(map[string]interface{}{
"ReferenceID": "some stripe invoice ID",
})
require.NoError(t, err)
credit10TX := billing.Transaction{
UserID: userID,
Amount: tenUSD,
Description: "credit from storjscan payment",
Source: billing.StorjScanSource,
Status: payments.PaymentStatusConfirmed,
Type: billing.TransactionTypeCredit,
Metadata: creditMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
debit10TX := billing.Transaction{
UserID: userID,
Amount: minusTenUSD,
Description: "Paid Stripe Invoice",
Source: billing.StripeSource,
Status: billing.TransactionStatusPending,
Type: billing.TransactionTypeDebit,
Metadata: debitMetadata,
Timestamp: makeTimestamp().Add(time.Second),
}
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
_, err := db.Billing().Insert(ctx, credit10TX)
require.NoError(t, err)
credit10TX.Status = payments.PaymentStatusConfirmed
tx, err := db.Billing().List(ctx, userID)
require.NoError(t, err)
compareTransactions(t, credit10TX, tx[0])
txIDs, err := db.Billing().Insert(ctx, debit10TX)
require.NoError(t, err)
err = db.Billing().FailPendingInvoiceTokenPayments(ctx, txIDs[0])
require.NoError(t, err)
debit10TX.Status = billing.TransactionStatusFailed
tx, err = db.Billing().List(ctx, userID)
require.NoError(t, err)
assert.Equal(t, 2, compareMultipleTransactions(t,
[]billing.Transaction{credit10TX, debit10TX}, tx))
})
}
func compareMultipleTransactions(t *testing.T, exp, act []billing.Transaction) int {
var matches = 0
for _, expectedTx := range exp {
for _, actualTX := range act {
if expectedTx.Description == actualTX.Description {
matches++
compareTransactions(t, expectedTx, actualTX)
}
}
}
return matches
}
// compareTransactions is a helper method to compare tx used to create db entry,
// with the tx returned from the db. Method doesn't compare created at field, but
// ensures that is not empty.
func compareTransactions(t *testing.T, exp, act billing.Transaction) {
assert.Equal(t, exp.UserID, act.UserID)
assert.Equal(t, currency.AmountFromDecimal(exp.Amount.AsDecimal().Truncate(currency.USDollarsMicro.DecimalPlaces()), currency.USDollarsMicro), act.Amount)
assert.Equal(t, exp.Description, act.Description)
assert.Equal(t, exp.Status, act.Status)
assert.Equal(t, exp.Source, act.Source)
assert.Equal(t, exp.Type, act.Type)
var expUpdatedMetadata map[string]interface{}
var actUpdatedMetadata map[string]interface{}
err := json.Unmarshal(exp.Metadata, &expUpdatedMetadata)
require.NoError(t, err)
err = json.Unmarshal(act.Metadata, &actUpdatedMetadata)
require.NoError(t, err)
assert.Equal(t, expUpdatedMetadata["ReferenceID"], actUpdatedMetadata["ReferenceID"])
assert.Equal(t, expUpdatedMetadata["Wallet"], actUpdatedMetadata["Wallet"])
assert.NotEqual(t, time.Time{}, act.CreatedAt)
}
func makeTimestamp() time.Time {
// Truncate to microseconds to paper over a loss of nanosecond precision
// going in and out of the database due to timestamp column resolution.
return time.Now().Truncate(time.Microsecond)
}