99206fca04
This change updates the stripecoinpayments service to optionally skip generating line items for payments records that have no egress, storage, or segments for the billing period. This results in a reduction from 4 to 1 Stripe API calls for customers who have no usage. The final API call is the attempt to generate an invoice on stripe, which expectedly fails because there are no unapplied line items. Removing that final API call would require some additional queries and is out of scope for this change. This functionality is behind the `payments.stripe-coin-payments.skip-empty-invoices` feature flag. https://github.com/storj/storj/issues/5381 Change-Id: Id184969a4c79047c40502336d69c51388ab03bf8
492 lines
17 KiB
Go
492 lines
17 KiB
Go
// Copyright (C) 2020 Storj Labs, Inc.
|
|
// See LICENSE for copying information.
|
|
|
|
package stripecoinpayments_test
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stripe/stripe-go/v72"
|
|
"go.uber.org/zap"
|
|
|
|
"storj.io/common/currency"
|
|
"storj.io/common/memory"
|
|
"storj.io/common/pb"
|
|
"storj.io/common/testcontext"
|
|
"storj.io/common/testrand"
|
|
"storj.io/common/uuid"
|
|
"storj.io/storj/private/blockchain"
|
|
"storj.io/storj/private/testplanet"
|
|
"storj.io/storj/satellite"
|
|
"storj.io/storj/satellite/accounting"
|
|
"storj.io/storj/satellite/console"
|
|
"storj.io/storj/satellite/metabase"
|
|
"storj.io/storj/satellite/payments/billing"
|
|
"storj.io/storj/satellite/payments/stripecoinpayments"
|
|
)
|
|
|
|
func TestService_InvoiceElementsProcessing(t *testing.T) {
|
|
testplanet.Run(t, testplanet.Config{
|
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
|
Reconfigure: testplanet.Reconfigure{
|
|
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
|
config.Payments.StripeCoinPayments.ListingLimit = 4
|
|
},
|
|
},
|
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
|
satellite := planet.Satellites[0]
|
|
|
|
// pick a specific date so that it doesn't fail if it's the last day of the month
|
|
// keep month + 1 because user needs to be created before calculation
|
|
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
|
|
|
|
numberOfProjects := 19
|
|
// generate test data, each user has one project and some credits
|
|
for i := 0; i < numberOfProjects; i++ {
|
|
user, err := satellite.AddUser(ctx, console.CreateUser{
|
|
FullName: "testuser" + strconv.Itoa(i),
|
|
Email: "user@test" + strconv.Itoa(i),
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
project, err := satellite.AddProject(ctx, user.ID, "testproject-"+strconv.Itoa(i))
|
|
require.NoError(t, err)
|
|
|
|
err = satellite.DB.Orders().UpdateBucketBandwidthSettle(ctx, project.ID, []byte("testbucket"),
|
|
pb.PieceAction_GET, int64(i+10)*memory.GiB.Int64(), 0, period)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
satellite.API.Payments.StripeService.SetNow(func() time.Time {
|
|
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
})
|
|
err := satellite.API.Payments.StripeService.PrepareInvoiceProjectRecords(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// check if we have project record for each project
|
|
recordsPage, err := satellite.DB.StripeCoinPayments().ProjectRecords().ListUnapplied(ctx, 0, 40, start, end)
|
|
require.NoError(t, err)
|
|
require.Equal(t, numberOfProjects, len(recordsPage.Records))
|
|
|
|
err = satellite.API.Payments.StripeService.InvoiceApplyProjectRecords(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
// verify that we applied all unapplied project records
|
|
recordsPage, err = satellite.DB.StripeCoinPayments().ProjectRecords().ListUnapplied(ctx, 0, 40, start, end)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, len(recordsPage.Records))
|
|
})
|
|
}
|
|
|
|
func TestService_InvoiceUserWithManyProjects(t *testing.T) {
|
|
testplanet.Run(t, testplanet.Config{
|
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
|
Reconfigure: testplanet.Reconfigure{
|
|
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
|
config.Payments.StripeCoinPayments.ListingLimit = 4
|
|
},
|
|
},
|
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
|
satellite := planet.Satellites[0]
|
|
payments := satellite.API.Payments
|
|
|
|
// pick a specific date so that it doesn't fail if it's the last day of the month
|
|
// keep month + 1 because user needs to be created before calculation
|
|
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
|
|
|
|
payments.StripeService.SetNow(func() time.Time {
|
|
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
})
|
|
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
numberOfProjects := 5
|
|
storageHours := 24
|
|
|
|
user, err := satellite.AddUser(ctx, console.CreateUser{
|
|
FullName: "testuser",
|
|
Email: "user@test",
|
|
}, numberOfProjects)
|
|
require.NoError(t, err)
|
|
|
|
projects := make([]*console.Project, numberOfProjects)
|
|
projectsEgress := make([]int64, len(projects))
|
|
projectsStorage := make([]int64, len(projects))
|
|
for i := 0; i < len(projects); i++ {
|
|
projects[i], err = satellite.AddProject(ctx, user.ID, "testproject-"+strconv.Itoa(i))
|
|
require.NoError(t, err)
|
|
|
|
projectsEgress[i] = int64(i+10) * memory.GiB.Int64()
|
|
projectsStorage[i] = int64(i+1) * memory.TiB.Int64()
|
|
totalSegments := int64(i + 1)
|
|
generateProjectStorage(ctx, t, satellite.DB,
|
|
projects[i].ID,
|
|
period,
|
|
period.Add(time.Duration(storageHours)*time.Hour),
|
|
projectsEgress[i],
|
|
projectsStorage[i],
|
|
totalSegments)
|
|
// verify that projects don't have records yet
|
|
projectRecord, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, projects[i].ID, start, end)
|
|
require.NoError(t, err)
|
|
require.Nil(t, projectRecord)
|
|
}
|
|
|
|
err = payments.StripeService.PrepareInvoiceProjectRecords(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
for i := 0; i < len(projects); i++ {
|
|
projectRecord, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, projects[i].ID, start, end)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, projectRecord)
|
|
require.Equal(t, projects[i].ID, projectRecord.ProjectID)
|
|
require.Equal(t, projectsEgress[i], projectRecord.Egress)
|
|
|
|
expectedStorage := float64(projectsStorage[i] * int64(storageHours))
|
|
require.Equal(t, expectedStorage, projectRecord.Storage)
|
|
|
|
expectedSegmentsCount := float64((i + 1) * storageHours)
|
|
require.Equal(t, expectedSegmentsCount, projectRecord.Segments)
|
|
}
|
|
|
|
// run all parts of invoice generation to see if there are no unexpected errors
|
|
err = payments.StripeService.InvoiceApplyProjectRecords(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
err = payments.StripeService.CreateInvoices(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
})
|
|
}
|
|
|
|
func TestService_ProjectsWithMembers(t *testing.T) {
|
|
testplanet.Run(t, testplanet.Config{
|
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
|
Reconfigure: testplanet.Reconfigure{
|
|
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
|
config.Payments.StripeCoinPayments.ListingLimit = 4
|
|
},
|
|
},
|
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
|
satellite := planet.Satellites[0]
|
|
|
|
// pick a specific date so that it doesn't fail if it's the last day of the month
|
|
// keep month + 1 because user needs to be created before calculation
|
|
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
|
|
|
|
numberOfUsers := 5
|
|
users := make([]*console.User, numberOfUsers)
|
|
projects := make([]*console.Project, numberOfUsers)
|
|
for i := 0; i < numberOfUsers; i++ {
|
|
var err error
|
|
|
|
users[i], err = satellite.AddUser(ctx, console.CreateUser{
|
|
FullName: "testuser" + strconv.Itoa(i),
|
|
Email: "user@test" + strconv.Itoa(i),
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
projects[i], err = satellite.AddProject(ctx, users[i].ID, "testproject-"+strconv.Itoa(i))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// all users are members in all projects
|
|
for _, project := range projects {
|
|
for _, user := range users {
|
|
if project.OwnerID != user.ID {
|
|
_, err := satellite.DB.Console().ProjectMembers().Insert(ctx, user.ID, project.ID)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
satellite.API.Payments.StripeService.SetNow(func() time.Time {
|
|
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
})
|
|
err := satellite.API.Payments.StripeService.PrepareInvoiceProjectRecords(ctx, period)
|
|
require.NoError(t, err)
|
|
|
|
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
recordsPage, err := satellite.DB.StripeCoinPayments().ProjectRecords().ListUnapplied(ctx, 0, 40, start, end)
|
|
require.NoError(t, err)
|
|
require.Equal(t, len(projects), len(recordsPage.Records))
|
|
})
|
|
}
|
|
|
|
func TestService_InvoiceItemsFromProjectRecord(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]
|
|
|
|
// these numbers are fraction of cents, not of dollars.
|
|
expectedStoragePrice := 0.001
|
|
expectedEgressPrice := 0.0045
|
|
expectedSegmentPrice := 0.00022
|
|
|
|
type TestCase struct {
|
|
Storage float64
|
|
Egress int64
|
|
Segments float64
|
|
|
|
StorageQuantity int64
|
|
EgressQuantity int64
|
|
SegmentsQuantity int64
|
|
}
|
|
|
|
testCases := []TestCase{
|
|
{}, // all zeros
|
|
{
|
|
Storage: 10000000000, // Byte-Hours
|
|
// storage quantity is calculated to Megabyte-Months
|
|
// (10000000000 / 1000000) Byte-Hours to Megabytes-Hours
|
|
// round(10000 / 720) Megabytes-Hours to Megabyte-Months, 720 - hours in month
|
|
StorageQuantity: 14, // Megabyte-Months
|
|
},
|
|
{
|
|
Egress: 134 * memory.GB.Int64(), // Bytes
|
|
// egress quantity is calculated to Megabytes
|
|
// (134000000000 / 1000000) Bytes to Megabytes
|
|
EgressQuantity: 134000, // Megabytes
|
|
},
|
|
{
|
|
Segments: 400000, // Segment-Hours
|
|
// object quantity is calculated to Segment-Months
|
|
// round(400000 / 720) Segment-Hours to Segment-Months, 720 - hours in month
|
|
SegmentsQuantity: 556, // Segment-Months
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
record := stripecoinpayments.ProjectRecord{
|
|
Storage: tc.Storage,
|
|
Egress: tc.Egress,
|
|
Segments: tc.Segments,
|
|
}
|
|
|
|
items := satellite.API.Payments.StripeService.InvoiceItemsFromProjectRecord("project name", record)
|
|
|
|
require.Equal(t, tc.StorageQuantity, *items[0].Quantity)
|
|
require.Equal(t, expectedStoragePrice, *items[0].UnitAmountDecimal)
|
|
|
|
require.Equal(t, tc.EgressQuantity, *items[1].Quantity)
|
|
require.Equal(t, expectedEgressPrice, *items[1].UnitAmountDecimal)
|
|
|
|
require.Equal(t, tc.SegmentsQuantity, *items[2].Quantity)
|
|
require.Equal(t, expectedSegmentPrice, *items[2].UnitAmountDecimal)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestService_InvoiceItemsFromZeroTokenBalance(t *testing.T) {
|
|
testplanet.Run(t, testplanet.Config{
|
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
|
Reconfigure: testplanet.Reconfigure{
|
|
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
|
config.Payments.StripeCoinPayments.ListingLimit = 4
|
|
},
|
|
},
|
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
|
satellite := planet.Satellites[0]
|
|
payments := satellite.API.Payments
|
|
|
|
user, err := satellite.AddUser(ctx, console.CreateUser{
|
|
FullName: "testuser",
|
|
Email: "user@test",
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
// setup storjscan wallet
|
|
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)
|
|
_, err = satellite.DB.Billing().Insert(ctx, billing.Transaction{
|
|
UserID: userID,
|
|
Amount: currency.AmountFromBaseUnits(1000, currency.USDollars),
|
|
Description: "token payment credit",
|
|
Source: "storjscan",
|
|
Status: billing.TransactionStatusCompleted,
|
|
Type: billing.TransactionTypeCredit,
|
|
Metadata: nil,
|
|
Timestamp: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// run apply token balance to see if there are no unexpected errors
|
|
err = payments.StripeService.InvoiceApplyTokenBalance(ctx, time.Time{})
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestService_GenerateInvoice(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
desc string
|
|
skipEmptyInvoices bool
|
|
addProjectUsage bool
|
|
expectInvoice bool
|
|
}{
|
|
{
|
|
desc: "invoice with non-empty usage created if not configured to skip",
|
|
skipEmptyInvoices: false,
|
|
addProjectUsage: true,
|
|
expectInvoice: true,
|
|
},
|
|
{
|
|
desc: "invoice with non-empty usage created if configured to skip",
|
|
skipEmptyInvoices: true,
|
|
addProjectUsage: true,
|
|
expectInvoice: true,
|
|
},
|
|
{
|
|
desc: "invoice with empty usage created if not configured to skip",
|
|
skipEmptyInvoices: false,
|
|
addProjectUsage: false,
|
|
expectInvoice: true,
|
|
},
|
|
{
|
|
desc: "invoice with empty usage not created if configured to skip",
|
|
skipEmptyInvoices: true,
|
|
addProjectUsage: false,
|
|
expectInvoice: false,
|
|
},
|
|
} {
|
|
t.Run(testCase.desc, func(t *testing.T) {
|
|
testplanet.Run(t, testplanet.Config{
|
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
|
Reconfigure: testplanet.Reconfigure{
|
|
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
|
|
config.Payments.StripeCoinPayments.SkipEmptyInvoices = testCase.skipEmptyInvoices
|
|
},
|
|
},
|
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
|
satellite := planet.Satellites[0]
|
|
payments := satellite.API.Payments
|
|
|
|
// pick a specific date so that it doesn't fail if it's the last day of the month
|
|
// keep month + 1 because user needs to be created before calculation
|
|
period := time.Date(time.Now().Year(), time.Now().Month()+1, 20, 0, 0, 0, 0, time.UTC)
|
|
|
|
payments.StripeService.SetNow(func() time.Time {
|
|
return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
})
|
|
start := time.Date(period.Year(), period.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
user, err := satellite.AddUser(ctx, console.CreateUser{
|
|
FullName: "Test User",
|
|
Email: "test@mail.test",
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
proj, err := satellite.AddProject(ctx, user.ID, "testproject")
|
|
require.NoError(t, err)
|
|
|
|
// optionally add some usage for the project
|
|
if testCase.addProjectUsage {
|
|
generateProjectStorage(ctx, t, satellite.DB,
|
|
proj.ID,
|
|
period,
|
|
period.Add(24*time.Hour),
|
|
100000,
|
|
200000,
|
|
99)
|
|
}
|
|
|
|
require.NoError(t, payments.StripeService.GenerateInvoices(ctx, start))
|
|
|
|
// ensure free tier coupon was applied
|
|
cusID, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID)
|
|
require.NoError(t, err)
|
|
|
|
stripeUser, err := payments.StripeClient.Customers().Get(cusID, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stripeUser.Discount)
|
|
require.NotNil(t, stripeUser.Discount.Coupon)
|
|
require.Equal(t, payments.StripeService.StripeFreeTierCouponID, stripeUser.Discount.Coupon.ID)
|
|
|
|
// ensure project record was generated
|
|
err = satellite.DB.StripeCoinPayments().ProjectRecords().Check(ctx, proj.ID, start, end)
|
|
require.ErrorIs(t, stripecoinpayments.ErrProjectRecordExists, err)
|
|
|
|
rec, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, proj.ID, start, end)
|
|
require.NotNil(t, rec)
|
|
require.NoError(t, err)
|
|
|
|
invoice, hasInvoice := getCustomerInvoice(payments.StripeClient, cusID)
|
|
invoiceItems := getCustomerInvoiceItems(payments.StripeClient, cusID)
|
|
|
|
// If invoicing empty usage invoices was skipped, then we don't
|
|
// expect an invoice or invoice items.
|
|
if !testCase.expectInvoice {
|
|
require.False(t, hasInvoice, "expected no invoice but got one")
|
|
require.Empty(t, invoiceItems, "not expecting any invoice items")
|
|
return
|
|
}
|
|
|
|
// Otherwise, we expect one or more line items that have been
|
|
// associated with the newly created invoice.
|
|
require.True(t, hasInvoice, "expected invoice but did not get one")
|
|
require.NotZero(t, len(invoiceItems), "expecting one or more invoice items")
|
|
for _, item := range invoiceItems {
|
|
require.Contains(t, item.Metadata, "projectID")
|
|
require.Equal(t, item.Metadata["projectID"], proj.ID.String())
|
|
require.NotNil(t, invoice, item.Invoice)
|
|
require.Equal(t, invoice.ID, item.Invoice.ID)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func getCustomerInvoice(stripeClient stripecoinpayments.StripeClient, cusID string) (*stripe.Invoice, bool) {
|
|
iter := stripeClient.Invoices().List(&stripe.InvoiceListParams{Customer: &cusID})
|
|
if iter.Next() {
|
|
return iter.Invoice(), true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func getCustomerInvoiceItems(stripeClient stripecoinpayments.StripeClient, cusID string) (items []*stripe.InvoiceItem) {
|
|
iter := stripeClient.InvoiceItems().List(&stripe.InvoiceItemListParams{Customer: &cusID})
|
|
for iter.Next() {
|
|
items = append(items, iter.InvoiceItem())
|
|
}
|
|
return items
|
|
}
|
|
|
|
func generateProjectStorage(ctx context.Context, tb testing.TB, db satellite.DB, projectID uuid.UUID, start, end time.Time, egress, totalBytes, totalSegments int64) {
|
|
// generate egress
|
|
err := db.Orders().UpdateBucketBandwidthSettle(ctx, projectID, []byte("testbucket"),
|
|
pb.PieceAction_GET, egress, 0, start)
|
|
require.NoError(tb, err)
|
|
|
|
// generate storage
|
|
tallies := map[metabase.BucketLocation]*accounting.BucketTally{
|
|
{}: {
|
|
BucketLocation: metabase.BucketLocation{
|
|
ProjectID: projectID,
|
|
BucketName: "testbucket",
|
|
},
|
|
TotalBytes: totalBytes,
|
|
TotalSegments: totalSegments,
|
|
},
|
|
}
|
|
err = db.ProjectAccounting().SaveTallies(ctx, start, tallies)
|
|
require.NoError(tb, err)
|
|
|
|
err = db.ProjectAccounting().SaveTallies(ctx, end, tallies)
|
|
require.NoError(tb, err)
|
|
}
|