8a1bedd367
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
428 lines
14 KiB
Go
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)
|
|
}
|