// Copyright (C) 2020 Storj Labs, Inc. // See LICENSE for copying information. package stripe_test import ( "context" "fmt" "math" "sort" "strconv" "testing" "time" "github.com/shopspring/decimal" "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/buckets" "storj.io/storj/satellite/console" "storj.io/storj/satellite/metabase" "storj.io/storj/satellite/payments" "storj.io/storj/satellite/payments/billing" "storj.io/storj/satellite/payments/paymentsconfig" stripe1 "storj.io/storj/satellite/payments/stripe" ) func TestService_BalanceInvoiceItems(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 numberOfUsers := 10 users := make([]*console.User, numberOfUsers) projects := make([]*console.Project, numberOfUsers) // create a bunch of users 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) } // give one of the users a stripe balance cusID, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, users[4].ID) require.NoError(t, err) _, err = payments.StripeClient.Customers().Update(cusID, &stripe.CustomerParams{ Params: stripe.Params{ Context: ctx, }, Balance: stripe.Int64(1000), }) require.NoError(t, err) // convert the stripe balance into an invoice item require.NoError(t, payments.StripeService.CreateBalanceInvoiceItems(ctx)) // check that the invoice item was created itr := payments.StripeClient.InvoiceItems().List(&stripe.InvoiceItemListParams{ Customer: stripe.String(cusID), }) require.True(t, itr.Next()) require.NoError(t, itr.Err()) require.Equal(t, int64(1000), itr.InvoiceItem().UnitAmount) // check that the stripe balance was reset cus, err := payments.StripeClient.Customers().Get(cusID, &stripe.CustomerParams{ Params: stripe.Params{ Context: ctx, }, }) require.NoError(t, err) require.Equal(t, int64(0), cus.Balance) }) } 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, uuid.UUID{}, 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, uuid.UUID{}, 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, uuid.UUID{}, 40, start, end) require.NoError(t, err) require.Equal(t, len(projects), len(recordsPage.Records)) }) } func TestService_InvoiceItemsFromProjectUsage(t *testing.T) { const ( projectName = "my-project" partnerName = "partner" noOverridePartnerName = "no-override" hoursPerMonth = 24 * 30 bytesPerMegabyte = int64(memory.MB / memory.B) byteHoursPerMBMonth = hoursPerMonth * bytesPerMegabyte ) var ( defaultPrice = paymentsconfig.ProjectUsagePrice{ StorageTB: "1", EgressTB: "2", Segment: "3", } partnerPrice = paymentsconfig.ProjectUsagePrice{ StorageTB: "4", EgressTB: "5", Segment: "6", EgressDiscountRatio: 0.5, } ) defaultModel, err := defaultPrice.ToModel() require.NoError(t, err) partnerModel, err := partnerPrice.ToModel() require.NoError(t, err) 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.UsagePrice = defaultPrice config.Payments.UsagePriceOverrides.SetMap(map[string]paymentsconfig.ProjectUsagePrice{ partnerName: partnerPrice, }) }, }, }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { usage := map[string]accounting.ProjectUsage{ "": { Storage: 10000000000, // Byte-hours Egress: 123 * memory.GB.Int64(), // Bytes SegmentCount: 200000, // Segment-Hours }, partnerName: { Storage: 20000000000, Egress: 456 * memory.GB.Int64(), SegmentCount: 400000, }, noOverridePartnerName: { Storage: 30000000000, Egress: 789 * memory.GB.Int64(), SegmentCount: 600000, }, } items := planet.Satellites[0].API.Payments.StripeService.InvoiceItemsFromProjectUsage(projectName, usage) require.Len(t, items, len(usage)*3) for i, tt := range []struct { name string partner string priceModel payments.ProjectUsagePriceModel }{ {"default pricing - no partner", "", defaultModel}, {"default pricing - no override for partner", noOverridePartnerName, defaultModel}, {"partner pricing", partnerName, partnerModel}, } { t.Run(tt.name, func(t *testing.T) { prefix := "Project " + projectName if tt.partner != "" { prefix += " (" + tt.partner + ")" } usage := usage[tt.partner] usage.Egress -= int64(math.Round(usage.Storage / hoursPerMonth * tt.priceModel.EgressDiscountRatio)) if usage.Egress < 0 { usage.Egress = 0 } expectedStorageQuantity := int64(math.Round(usage.Storage / float64(byteHoursPerMBMonth))) expectedEgressQuantity := int64(math.Round(float64(usage.Egress) / float64(bytesPerMegabyte))) expectedSegmentQuantity := int64(math.Round(usage.SegmentCount / hoursPerMonth)) items := items[i*3 : (i*3)+3] for _, item := range items { require.NotNil(t, item) } require.Equal(t, prefix+" - Segment Storage (MB-Month)", *items[0].Description) require.Equal(t, expectedStorageQuantity, *items[0].Quantity) storage, _ := tt.priceModel.StorageMBMonthCents.Float64() require.Equal(t, storage, *items[0].UnitAmountDecimal) require.Equal(t, prefix+" - Egress Bandwidth (MB)", *items[1].Description) require.Equal(t, expectedEgressQuantity, *items[1].Quantity) egress, _ := tt.priceModel.EgressMBCents.Float64() require.Equal(t, egress, *items[1].UnitAmountDecimal) require.Equal(t, prefix+" - Segment Fee (Segment-Month)", *items[2].Description) require.Equal(t, expectedSegmentQuantity, *items[2].Quantity) segment, _ := tt.priceModel.SegmentMonthCents.Float64() require.Equal(t, segment, *items[2].UnitAmountDecimal) }) } }) } func TestService_PayInvoiceFromTokenBalance(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 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)) 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: tokenBalance, 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) // 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()) }) } 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 config.Payments.StripeCoinPayments.StripeFreeTierCouponID = stripe1.MockCouponID1 }, }, }, 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 project record was generated err = satellite.DB.StripeCoinPayments().ProjectRecords().Check(ctx, proj.ID, start, end) require.ErrorIs(t, stripe1.ErrProjectRecordExists, err) rec, err := satellite.DB.StripeCoinPayments().ProjectRecords().Get(ctx, proj.ID, start, end) require.NotNil(t, rec) require.NoError(t, err) // validate generated invoices cusID, err := satellite.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID) require.NoError(t, err) invoice, hasInvoice := getCustomerInvoice(ctx, payments.StripeClient, cusID) invoiceItems := getCustomerInvoiceItems(ctx, 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(ctx context.Context, stripeClient stripe1.Client, cusID string) (*stripe.Invoice, bool) { iter := stripeClient.Invoices().List(&stripe.InvoiceListParams{ ListParams: stripe.ListParams{Context: ctx}, Customer: &cusID, }) if iter.Next() { return iter.Invoice(), true } return nil, false } func getCustomerInvoiceItems(ctx context.Context, stripeClient stripe1.Client, cusID string) (items []*stripe.InvoiceItem) { iter := stripeClient.InvoiceItems().List(&stripe.InvoiceItemListParams{ ListParams: stripe.ListParams{Context: ctx}, 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) } func TestProjectUsagePrice(t *testing.T) { var ( defaultPrice = paymentsconfig.ProjectUsagePrice{ StorageTB: "1", EgressTB: "2", Segment: "3", } partnerName = "partner" partnerPrice = paymentsconfig.ProjectUsagePrice{ StorageTB: "4", EgressTB: "5", Segment: "6", } ) defaultModel, err := defaultPrice.ToModel() require.NoError(t, err) partnerModel, err := partnerPrice.ToModel() require.NoError(t, err) 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.UsagePrice = defaultPrice config.Payments.UsagePriceOverrides.SetMap(map[string]paymentsconfig.ProjectUsagePrice{ partnerName: partnerPrice, }) }, }, }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { sat := 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) sat.API.Payments.StripeService.SetNow(func() time.Time { return time.Date(period.Year(), period.Month()+1, 1, 0, 0, 0, 0, time.UTC) }) for i, tt := range []struct { name string userAgent []byte expectedPrice payments.ProjectUsagePriceModel }{ {"default pricing", nil, defaultModel}, {"default pricing - user agent is not valid partner name", []byte("invalid/v0.0"), defaultModel}, {"partner pricing - user agent is partner name", []byte(partnerName), partnerModel}, {"partner pricing - user agent prefixed with partner name", []byte(partnerName + " invalid/v0.0"), partnerModel}, } { t.Run(tt.name, func(t *testing.T) { user, err := sat.AddUser(ctx, console.CreateUser{ FullName: "Test User", Email: fmt.Sprintf("user%d@mail.test", i), UserAgent: tt.userAgent, }, 1) require.NoError(t, err) project, err := sat.AddProject(ctx, user.ID, "testproject") require.NoError(t, err) bucket, err := sat.DB.Buckets().CreateBucket(ctx, buckets.Bucket{ ID: testrand.UUID(), Name: testrand.BucketName(), ProjectID: project.ID, UserAgent: tt.userAgent, }) require.NoError(t, err) err = sat.DB.Orders().UpdateBucketBandwidthSettle(ctx, project.ID, []byte(bucket.Name), pb.PieceAction_GET, memory.TB.Int64(), 0, period) require.NoError(t, err) err = sat.API.Payments.StripeService.PrepareInvoiceProjectRecords(ctx, period) require.NoError(t, err) err = sat.API.Payments.StripeService.InvoiceApplyProjectRecords(ctx, period) require.NoError(t, err) cusID, err := sat.DB.StripeCoinPayments().Customers().GetCustomerID(ctx, user.ID) require.NoError(t, err) items := getCustomerInvoiceItems(ctx, sat.API.Payments.StripeClient, cusID) require.Len(t, items, 3) sort.Slice(items, func(i, j int) bool { return items[i].Description < items[j].Description }) egress, _ := tt.expectedPrice.EgressMBCents.Float64() require.Equal(t, egress, items[0].UnitAmountDecimal) segment, _ := tt.expectedPrice.SegmentMonthCents.Float64() require.Equal(t, segment, items[1].UnitAmountDecimal) storage, _ := tt.expectedPrice.StorageMBMonthCents.Float64() require.Equal(t, storage, items[2].UnitAmountDecimal) }) } }) } func TestPayInvoicesSkipDue(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] cus1 := "cus_1" cus2 := "cus_2" amount := int64(100) curr := string(stripe.CurrencyUSD) due := time.Now().Add(14 * 24 * time.Hour).Unix() _, err := satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ Params: stripe.Params{Context: ctx}, Amount: &amount, Currency: &curr, Customer: &cus1, }) require.NoError(t, err) _, err = satellite.API.Payments.StripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{ Params: stripe.Params{Context: ctx}, Amount: &amount, Currency: &curr, Customer: &cus2, }) require.NoError(t, err) inv, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{ Params: stripe.Params{Context: ctx}, Customer: &cus1, }) require.NoError(t, err) finalizeParams := &stripe.InvoiceFinalizeParams{Params: stripe.Params{Context: ctx}} inv, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(inv.ID, finalizeParams) require.NoError(t, err) require.Equal(t, stripe.InvoiceStatusOpen, inv.Status) invWithDue, err := satellite.API.Payments.StripeClient.Invoices().New(&stripe.InvoiceParams{ Params: stripe.Params{Context: ctx}, Customer: &cus2, DueDate: &due, }) require.NoError(t, err) invWithDue, err = satellite.API.Payments.StripeClient.Invoices().FinalizeInvoice(invWithDue.ID, finalizeParams) require.NoError(t, err) require.Equal(t, stripe.InvoiceStatusOpen, invWithDue.Status) 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() { i := iter.Invoice() if i.ID == inv.ID { require.Equal(t, stripe.InvoiceStatusPaid, i.Status) } // when due date is set invoice should not be paid if i.ID == invWithDue.ID { require.Equal(t, stripe.InvoiceStatusOpen, i.Status) } } }) } func TestRemoveExpiredPackageCredit(t *testing.T) { testplanet.Run(t, testplanet.Config{ SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 4, }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { satellite := planet.Satellites[0] p := satellite.API.Payments u0 := planet.Uplinks[0].Projects[0].Owner.ID u1 := planet.Uplinks[1].Projects[0].Owner.ID u2 := planet.Uplinks[2].Projects[0].Owner.ID u3 := planet.Uplinks[3].Projects[0].Owner.ID credit := int64(1000) pkgDesc := "test package plan" now := time.Now() expiredPurchase := now.AddDate(-1, -1, 0) removeExpiredCredit := func(u uuid.UUID, expectAlert bool, purchaseTime *time.Time) { require.NoError(t, p.Accounts.UpdatePackage(ctx, u, &pkgDesc, purchaseTime)) cPage, err := satellite.API.DB.StripeCoinPayments().Customers().List(ctx, uuid.UUID{}, 10, time.Now().Add(1*time.Hour)) require.NoError(t, err) var c stripe1.Customer for _, cus := range cPage.Customers { if cus.UserID == u { c = cus } } alertSent, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{ ID: c.ID, UserID: c.UserID, PackagePlan: c.PackagePlan, PackagePurchasedAt: c.PackagePurchasedAt, }) require.NoError(t, err) if expectAlert { require.True(t, alertSent) } else { require.False(t, alertSent) } } checkCreditAndPackage := func(u uuid.UUID, expectedBalance int64, expectNilPackage bool) { b, err := p.Accounts.Balances().Get(ctx, u) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(expectedBalance), b.Credits) dbPkgInfo, dbPurchaseTime, err := p.StripeService.Accounts().GetPackageInfo(ctx, u) require.NoError(t, err) if expectNilPackage { require.Nil(t, dbPkgInfo) require.Nil(t, dbPurchaseTime) } else { require.NotNil(t, dbPkgInfo) require.NotNil(t, dbPurchaseTime) } } t.Run("nil package plan returns safely", func(t *testing.T) { _, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{ ID: "test-customer-ID", UserID: testrand.UUID(), PackagePlan: nil, PackagePurchasedAt: &now, }) require.NoError(t, err) }) t.Run("nil package purchase time returns safely", func(t *testing.T) { _, err := p.StripeService.RemoveExpiredPackageCredit(ctx, stripe1.Customer{ ID: "test-customer-ID", UserID: testrand.UUID(), PackagePlan: new(string), PackagePurchasedAt: nil, }) require.NoError(t, err) }) t.Run("package not expired retains credit", func(t *testing.T) { b, err := p.Accounts.Balances().ApplyCredit(ctx, u3, credit, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(credit), b.Credits) removeExpiredCredit(u3, false, &now) checkCreditAndPackage(u3, credit, false) }) t.Run("used all credit", func(t *testing.T) { b, err := p.Accounts.Balances().ApplyCredit(ctx, u0, credit, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(credit), b.Credits) // remove credit as if they used it all b, err = p.Accounts.Balances().ApplyCredit(ctx, u0, -credit, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(0), b.Credits) removeExpiredCredit(u0, false, &expiredPurchase) checkCreditAndPackage(u0, 0, true) }) t.Run("has remaining credit but no credit source other than package", func(t *testing.T) { b, err := p.Accounts.Balances().ApplyCredit(ctx, u1, credit, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(credit), b.Credits) // remove some credit, but not all, as if it were used toRemove := credit / 2 remaining := credit - toRemove b, err = p.Accounts.Balances().ApplyCredit(ctx, u1, -toRemove, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(remaining), b.Credits) removeExpiredCredit(u1, false, &expiredPurchase) checkCreditAndPackage(u1, 0, true) }) t.Run("has additional credit source", func(t *testing.T) { b, err := p.Accounts.Balances().ApplyCredit(ctx, u2, credit, pkgDesc) require.NoError(t, err) require.Equal(t, decimal.NewFromInt(credit), b.Credits) // give additional credit additional := int64(2000) b, err = p.Accounts.Balances().ApplyCredit(ctx, u2, additional, "additional credit") require.NoError(t, err) require.Equal(t, decimal.NewFromInt(credit+additional), b.Credits) removeExpiredCredit(u2, true, &expiredPurchase) checkCreditAndPackage(u2, credit+additional, false) }) }) }