package main import ( "context" "encoding/json" "errors" "fmt" "maps" "net/http" "strconv" "strings" "sync" "testing" "time" "github.com/go-resty/resty/v2" "github.com/shellhub-io/shellhub/pkg/api/requests" "github.com/shellhub-io/shellhub/pkg/api/responses" "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/pkg/uuid" "github.com/shellhub-io/shellhub/tests/environment" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( freeTierDevices = 3 signUpNamespaceLimit = 1 unlimitedDevices = -1 acceptedTestCard = "pm_card_visa" decliningTestCard = "pm_card_chargeCustomerFail" billingBlockedLog = "doesn't qualify for the free plan" deviceLimitMessage = "device limit reached" subscriptionBlockedMessage = "the namespace's subscription blocks new devices" stripeWaitFor = time.Minute stripeUsageQuietCheck = 10 * time.Second testClockStep = 30 * 24 * time.Hour testClockSteps = 5 testClockWaitFor = 5 * time.Minute stripeTestEmailDomain = "@shellhub.test" stripeTestClockName = "shellhub-e2e" stripeLeftoverAge = 2 * time.Hour ) const ( stripeInvoicePaid = "invoice.paid" stripeCustomerDeleted = "customer.deleted" stripeSubscriptionUpdated = "customer.subscription.updated" stripeSubscriptionDeleted = "customer.subscription.deleted" ) // TestCloudBilling runs a cloud instance billing through Stripe test mode. Each case owns a // namespace of its own, and every subscription a case starts is a real one on the test account, // deleted with its customer when the case ends. Stripe's own events never reach the instance: // a case delivers the event it needs, signed with the webhook secret the server verifies. Before // the cases run, it deletes the customers and test clocks an earlier run left on the account. func TestCloudBilling(t *testing.T) { t.Parallel() ctx := context.Background() compose := environment.New(t, run).WithEdition(environment.EditionCloud).Up(ctx) t.Cleanup(compose.Down) sweepStripeLeftovers(t, compose) t.Run("device acceptance", func(t *testing.T) { testBilledDeviceAcceptance(t, compose) }) t.Run("device connection", func(t *testing.T) { testBilledDeviceConnection(t, ctx, compose) }) t.Run("webhook", func(t *testing.T) { testBillingWebhook(t, compose) }) t.Run("namespace deletion", func(t *testing.T) { testBilledNamespaceDeletion(t, compose) }) t.Run("subscription", func(t *testing.T) { testBillingSubscription(t, compose) }) t.Run("payment methods", func(t *testing.T) { testBillingPaymentMethods(t, compose) }) t.Run("device chooser", func(t *testing.T) { testBillingDeviceChooser(t, compose) }) t.Run("permissions", func(t *testing.T) { testBillingPermissions(t, compose) }) } type billingOwner struct { username string tenant string key string } type billedSubscription struct { tenant string customer string id string } func newBillingOwner(t *testing.T, compose *environment.DockerCompose, name string) billingOwner { t.Helper() owner := billingOwner{username: name, tenant: uuid.Generate()} compose.NewUser(t, name, name+stripeTestEmailDomain, ShellHubPassword) compose.LimitOwnedNamespaces(t, name, signUpNamespaceLimit) compose.NewNamespace(t, name, name, owner.tenant) compose.JWT(compose.AuthUser(t, name, ShellHubPassword).Token) owner.key = compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{Name: name, Mode: string(models.ProvisioningKeyModeManual)}).Key return owner } func (o billingOwner) maxNamespaces(t *testing.T, compose *environment.DockerCompose) int { t.Helper() return compose.AuthUser(t, o.username, ShellHubPassword).MaxNamespaces } func billedNamespace(t *testing.T, compose *environment.DockerCompose, tenant string) responses.Namespace { t.Helper() namespace := responses.Namespace{} resp, err := compose.R(t.Context()).SetResult(&namespace).Get("/api/namespaces/" + tenant) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.NotNil(t, namespace.Billing, "namespace %s has no billing", tenant) return namespace } func subscriptionStatus(t *testing.T, compose *environment.DockerCompose, tenant string) models.BillingStatus { t.Helper() billing := billedNamespace(t, compose, tenant).Billing require.NotNil(t, billing.Subscription) return billing.Subscription.Status } func subscribe(t *testing.T, compose *environment.DockerCompose, tenant string) billedSubscription { t.Helper() return startSubscription(t, compose, tenant, becomeCustomer(t, compose, tenant), acceptedTestCard) } func becomeCustomer(t *testing.T, compose *environment.DockerCompose, tenant string) string { t.Helper() var customer string t.Cleanup(func() { if customer == "" { customer = namespaceCustomer(t, compose, tenant) } if customer != "" { deleteStripeObject(t, compose, "/customers/"+customer) } }) resp, err := compose.R(t.Context()).SetBody(map[string]any{}).Post("/api/billing/customer") require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode(), resp.String()) billing := billedNamespace(t, compose, tenant).Billing require.NotEmpty(t, billing.CustomerID) customer = billing.CustomerID return customer } func namespaceCustomer(t *testing.T, compose *environment.DockerCompose, tenant string) string { t.Helper() namespace := responses.Namespace{} resp, err := compose.R(context.WithoutCancel(t.Context())).SetResult(&namespace).Get("/api/namespaces/" + tenant) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) if !namespace.Billing.HasCustomer() { return "" } return namespace.Billing.CustomerID } func attachCard(t *testing.T, compose *environment.DockerCompose, card string) { t.Helper() postBillingCard(t, compose, "attach", card, http.StatusOK) } func startSubscription(t *testing.T, compose *environment.DockerCompose, tenant, customer, card string) billedSubscription { t.Helper() attachCard(t, compose, card) postBillingSubscription(t, compose, http.StatusCreated) namespace := billedNamespace(t, compose, tenant) require.NotNil(t, namespace.Billing.Subscription) require.Equal(t, models.BillingStatusActive, namespace.Billing.Subscription.Status) require.Equal(t, unlimitedDevices, namespace.MaxDevices) return billedSubscription{tenant: tenant, customer: customer, id: namespace.Billing.Subscription.ID} } func enrollIn(t *testing.T, compose *environment.DockerCompose, provisioningKey, hostname, mac string) models.Device { t.Helper() req := newKeyedDeviceAuthRequest(t, provisioningKey, hostname, mac) device := enroll(t, compose, req) require.Equal(t, models.DeviceStatusPending, device.Status) return device } func acceptDevicesIn(t *testing.T, compose *environment.DockerCompose, provisioningKey, prefix string, count int) { t.Helper() for i := range count { device := enrollIn(t, compose, provisioningKey, fmt.Sprintf("%s-%d", prefix, i), fmt.Sprintf("02:00:00:00:43:%02x", i)) compose.UpdateDeviceStatus(t, device.UID, environment.DeviceActionAccept) } } func (s billedSubscription) object(fields map[string]any) map[string]any { object := map[string]any{ "id": s.id, "object": "subscription", "customer": s.customer, "status": string(models.BillingStatusActive), "cancel_at_period_end": false, "billing_cycle_anchor": wallClock.Now().Unix(), "current_period_end": wallClock.Now().AddDate(0, 1, 0).Unix(), "metadata": map[string]any{"tenant_id": s.tenant}, } maps.Copy(object, fields) return object } func (s billedSubscription) storeIn(t *testing.T, compose *environment.DockerCompose, status models.BillingStatus) { t.Helper() compose.SubscribeNamespaceThrough(t, s.tenant, s.customer, s.id, status) } func (s billedSubscription) inStatus(status models.BillingStatus) map[string]any { return s.object(map[string]any{"status": string(status)}) } func (s billedSubscription) canceled(at int64) map[string]any { return s.object(map[string]any{"status": string(models.BillingStatusCanceled), "canceled_at": at}) } func (s billedSubscription) invoice() map[string]any { return map[string]any{ "id": "in_" + stripeTestID(), "object": "invoice", "customer": s.customer, "subscription": s.id, } } func (s billedSubscription) customerObject(fields map[string]any) map[string]any { object := map[string]any{ "id": s.customer, "object": "customer", "metadata": map[string]any{"tenant_id": s.tenant}, } maps.Copy(object, fields) return object } func stripeTestID() string { return strings.ReplaceAll(uuid.Generate(), "-", "") } func stripeEvent(t *testing.T, eventType string, object map[string]any) []byte { t.Helper() payload, err := json.Marshal(map[string]any{ "id": "evt_" + stripeTestID(), "object": "event", "created": wallClock.Now().Unix(), "livemode": false, "type": eventType, "data": map[string]any{"object": object}, }) require.NoError(t, err) return payload } func deliverStripeEvent(t *testing.T, compose *environment.DockerCompose, eventType string, object map[string]any) *resty.Response { t.Helper() payload := stripeEvent(t, eventType, object) resp, err := compose.PostStripeWebhook(t.Context(), payload, compose.StripeSignature(payload)) require.NoError(t, err) return resp } func requireStripeEventAnswered(t *testing.T, compose *environment.DockerCompose, eventType string, object map[string]any) { t.Helper() resp := deliverStripeEvent(t, compose, eventType, object) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) } func cancelStripeSubscription(t *testing.T, compose *environment.DockerCompose, subscription string) int64 { t.Helper() canceled := struct { Status string `json:"status"` CanceledAt int64 `json:"canceled_at"` }{} resp, err := compose.StripeAPI(t.Context()).SetResult(&canceled).Delete("/subscriptions/" + subscription) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.Equal(t, string(models.BillingStatusCanceled), canceled.Status) return canceled.CanceledAt } func stripeSubscriptionItem(ctx context.Context, compose *environment.DockerCompose, subscription string) (string, error) { sub := struct { Items struct { Data []struct { ID string `json:"id"` } `json:"data"` } `json:"items"` }{} resp, err := compose.StripeAPI(ctx).SetResult(&sub).Get("/subscriptions/" + subscription) if err != nil { return "", err } if resp.StatusCode() != http.StatusOK || len(sub.Items.Data) == 0 { return "", fmt.Errorf("reading the subscription %s answered %d: %s", subscription, resp.StatusCode(), resp.String()) } return sub.Items.Data[0].ID, nil } func stripeUsage(ctx context.Context, compose *environment.DockerCompose, subscription string) (int64, error) { item, err := stripeSubscriptionItem(ctx, compose, subscription) if err != nil { return 0, err } summaries := struct { Data []struct { TotalUsage int64 `json:"total_usage"` } `json:"data"` }{} resp, err := compose.StripeAPI(ctx). SetQueryParam("limit", "1"). SetResult(&summaries). Get("/subscription_items/" + item + "/usage_record_summaries") if err != nil { return 0, err } if resp.StatusCode() != http.StatusOK || len(summaries.Data) == 0 { return 0, fmt.Errorf("reading the usage of %s answered %d: %s", item, resp.StatusCode(), resp.String()) } return summaries.Data[0].TotalUsage, nil } func awaitStripeUsage(t *testing.T, compose *environment.DockerCompose, subscription string, usage int64) { t.Helper() require.EventuallyWithT(t, func(tt *assert.CollectT) { current, err := stripeUsage(t.Context(), compose, subscription) if assert.NoError(tt, err) { assert.Equal(tt, usage, current) } }, stripeWaitFor, 2*time.Second) } func requireStripeUsageHolds(t *testing.T, compose *environment.DockerCompose, subscription string, usage int64) { t.Helper() require.Never(t, func() bool { current, err := stripeUsage(t.Context(), compose, subscription) return err == nil && current != usage }, stripeUsageQuietCheck, 2*time.Second, "Stripe's usage for %s was not held at %d", subscription, usage) current, err := stripeUsage(t.Context(), compose, subscription) require.NoError(t, err) require.Equal(t, usage, current) } func setStripeUsage(t *testing.T, compose *environment.DockerCompose, subscription string, usage int64) { t.Helper() item, err := stripeSubscriptionItem(t.Context(), compose, subscription) require.NoError(t, err) resp, err := compose.StripeAPI(t.Context()). SetFormData(map[string]string{ "quantity": strconv.FormatInt(usage, 10), "action": "set", "timestamp": "now", }). Post("/subscription_items/" + item + "/usage_records") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) awaitStripeUsage(t, compose, subscription, usage) } func testBilledDeviceAcceptance(t *testing.T, compose *environment.DockerCompose) { t.Helper() t.Run("a namespace without a subscription is refused a device past the free tier until it subscribes", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-free-tier") acceptDevicesIn(t, compose, owner.key, "free-tier", freeTierDevices) device := enrollIn(t, compose, owner.key, "past-free-tier", "02:00:00:00:44:00") requireAcceptRefused(t, compose, device, deviceLimitMessage) subscribe(t, compose, owner.tenant) requireAcceptAnswers(t, compose, device, http.StatusOK) }) t.Run("accepting a device reports it to Stripe", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-usage") subscription := subscribe(t, compose, owner.tenant) awaitStripeUsage(t, compose, subscription.id, 0) device := enrollIn(t, compose, owner.key, "reported", "02:00:00:00:44:01") requireAcceptAnswers(t, compose, device, http.StatusOK) awaitStripeUsage(t, compose, subscription.id, 1) }) t.Run("a past-due subscription refuses accepting a device and reports nothing", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-past-due") subscription := subscribe(t, compose, owner.tenant) awaitStripeUsage(t, compose, subscription.id, 0) requireStripeEventAnswered(t, compose, stripeSubscriptionUpdated, subscription.inStatus(models.BillingStatusPastDue)) require.Equal(t, models.BillingStatusPastDue, subscriptionStatus(t, compose, owner.tenant)) device := enrollIn(t, compose, owner.key, "past-due", "02:00:00:00:44:02") requireAcceptAnswers(t, compose, device, http.StatusPaymentRequired) requireStripeUsageHolds(t, compose, subscription.id, 0) }) t.Run("an unpaid subscription blocks accepting a device", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-unpaid") subscription := subscribe(t, compose, owner.tenant) requireStripeEventAnswered(t, compose, stripeSubscriptionUpdated, subscription.inStatus(models.BillingStatusUnpaid)) require.Equal(t, models.BillingStatusUnpaid, subscriptionStatus(t, compose, owner.tenant)) device := enrollIn(t, compose, owner.key, "unpaid", "02:00:00:00:44:03") requireAcceptRefused(t, compose, device, subscriptionBlockedMessage) }) } func requireAcceptRefused(t *testing.T, compose *environment.DockerCompose, device models.Device, message string) { t.Helper() refusal := responses.Error{} resp, err := compose.PatchDeviceStatus(t.Context(), device.UID, environment.DeviceActionAccept) require.NoError(t, err) require.Equal(t, http.StatusPaymentRequired, resp.StatusCode(), resp.String()) require.NoError(t, json.Unmarshal(resp.Body(), &refusal), resp.String()) assert.Equal(t, message, refusal.Message) assert.Equal(t, models.DeviceStatusPending, requireDevice(t, compose, device.UID).Status) } func testBilledDeviceConnection(t *testing.T, ctx context.Context, compose *environment.DockerCompose) { t.Helper() compose.NewUser(t, ShellHubUsername, ShellHubUsername+stripeTestEmailDomain, ShellHubPassword) ownNamespace(t, ctx, compose, newNamespace) subscription := subscribe(t, compose, ShellHubNamespace) acceptDevicesIn(t, compose, requireHarnessProvisioningKey(t, compose), "connection", freeTierDevices) signer, _ := enrollOwnerKey(t, compose) _, device := startAcceptedAgent(t, ctx, compose) t.Run("an active subscription lets SSH through past the free tier", func(t *testing.T) { conn := dialDevice(t, ctx, compose, device, signer) defer conn.Close() //nolint:errcheck // the test is over once the command answered assert.Equal(t, "billed\n", runOnDevice(t, conn, "echo billed")) }) t.Run("a canceled subscription blocks SSH past the free tier", func(t *testing.T) { canceledAt := cancelStripeSubscription(t, compose, subscription.id) requireStripeEventAnswered(t, compose, stripeSubscriptionDeleted, subscription.canceled(canceledAt)) requireAccessDenied(t, compose, deviceSSHID(device), signer, billingBlockedLog) }) } func testBillingWebhook(t *testing.T, compose *environment.DockerCompose) { t.Helper() t.Run("invoice.paid keeps an active subscription active and sets the period's usage to the accepted devices", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-invoice-paid") acceptDevicesIn(t, compose, owner.key, "invoiced", freeTierDevices) subscription := subscribe(t, compose, owner.tenant) awaitStripeUsage(t, compose, subscription.id, 0) requireStripeEventAnswered(t, compose, stripeInvoicePaid, subscription.invoice()) awaitStripeUsage(t, compose, subscription.id, freeTierDevices) assert.Equal(t, models.BillingStatusActive, subscriptionStatus(t, compose, owner.tenant)) }) t.Run("customer.deleted clears billing and reverts to the free tier", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-cus-deleted") maxNamespaces := owner.maxNamespaces(t, compose) subscription := subscribe(t, compose, owner.tenant) require.Equal(t, maxNamespaces+1, owner.maxNamespaces(t, compose)) deleteStripeObject(t, compose, "/customers/"+subscription.customer) requireStripeEventAnswered(t, compose, stripeCustomerDeleted, subscription.customerObject(map[string]any{"deleted": true})) namespace := billedNamespace(t, compose, owner.tenant) assert.Empty(t, namespace.Billing.CustomerID) assert.Nil(t, namespace.Billing.Subscription) assert.Equal(t, freeTierDevices, namespace.MaxDevices) assert.Equal(t, maxNamespaces, owner.maxNamespaces(t, compose)) }) t.Run("customer.subscription.deleted cancels the subscription and takes back the namespace it allowed", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-sub-deleted") maxNamespaces := owner.maxNamespaces(t, compose) subscription := subscribe(t, compose, owner.tenant) require.Equal(t, maxNamespaces+1, owner.maxNamespaces(t, compose)) canceledAt := cancelStripeSubscription(t, compose, subscription.id) requireStripeEventAnswered(t, compose, stripeSubscriptionDeleted, subscription.canceled(canceledAt)) namespace := billedNamespace(t, compose, owner.tenant) require.NotNil(t, namespace.Billing.Subscription) assert.Equal(t, models.BillingStatusCanceled, namespace.Billing.Subscription.Status) assert.Equal(t, canceledAt, namespace.Billing.Subscription.CurrentPeriodEnd) assert.Equal(t, freeTierDevices, namespace.MaxDevices) assert.Equal(t, maxNamespaces, owner.maxNamespaces(t, compose)) }) t.Run("concurrent invoice.paid events re-subscribe a namespace canceled for a failed payment once", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-resubscribe") maxNamespaces := owner.maxNamespaces(t, compose) testClock := newStripeTestClock(t, compose) customer := createStripeCustomer(t, compose, owner.tenant, testClock.id) compose.BillNamespaceThrough(t, owner.tenant, customer) subscription := startSubscription(t, compose, owner.tenant, customer, decliningTestCard) setStripeUsage(t, compose, subscription.id, freeTierDevices+1) canceledAt := testClock.advanceUntilCanceledForPayment(t, compose, subscription.id) requireStripeEventAnswered(t, compose, stripeSubscriptionDeleted, subscription.canceled(canceledAt)) require.Equal(t, maxNamespaces, owner.maxNamespaces(t, compose)) attachCard(t, compose, acceptedTestCard) payOpenInvoices(t, compose, subscription) statuses := deliverConcurrently(t, compose, stripeEvent(t, stripeInvoicePaid, subscription.invoice()), stripeEvent(t, stripeInvoicePaid, subscription.invoice())) assert.Equal(t, []int{http.StatusOK, http.StatusOK}, statuses) namespace := billedNamespace(t, compose, owner.tenant) require.NotNil(t, namespace.Billing.Subscription) assert.NotEqual(t, subscription.id, namespace.Billing.Subscription.ID) assert.Equal(t, models.BillingStatusActive, namespace.Billing.Subscription.Status) assert.Equal(t, unlimitedDevices, namespace.MaxDevices) assert.Equal(t, maxNamespaces+1, owner.maxNamespaces(t, compose)) assert.Equal(t, 1, activeStripeSubscriptions(t, compose, customer)) }) } func testBilledNamespaceDeletion(t *testing.T, compose *environment.DockerCompose) { t.Helper() t.Run("a namespace with an active subscription is deleted only once the subscription is canceled", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-active-del") subscription := subscribe(t, compose, owner.tenant) resp, err := compose.R(t.Context()).Delete("/api/namespaces/" + owner.tenant) require.NoError(t, err) require.Equal(t, http.StatusPaymentRequired, resp.StatusCode(), resp.String()) assert.Equal(t, models.BillingStatusActive, subscriptionStatus(t, compose, owner.tenant)) canceledAt := cancelStripeSubscription(t, compose, subscription.id) requireStripeEventAnswered(t, compose, stripeSubscriptionDeleted, subscription.canceled(canceledAt)) resp, err = compose.R(t.Context()).Delete("/api/namespaces/" + owner.tenant) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) t.Run("a namespace whose canceled subscription has an open invoice is deleted only once the invoice is voided", func(t *testing.T) { owner := newBillingOwner(t, compose, "billing-open-invoice") subscription := subscribe(t, compose, owner.tenant) invoice := openStripeInvoice(t, compose, subscription) canceledAt := cancelStripeSubscription(t, compose, subscription.id) requireStripeEventAnswered(t, compose, stripeSubscriptionDeleted, subscription.canceled(canceledAt)) resp, err := compose.R(t.Context()).Delete("/api/namespaces/" + owner.tenant) require.NoError(t, err) require.Equal(t, http.StatusPaymentRequired, resp.StatusCode(), resp.String()) assert.Equal(t, owner.tenant, billedNamespace(t, compose, owner.tenant).TenantID) voidStripeInvoice(t, compose, invoice) resp, err = compose.R(t.Context()).Delete("/api/namespaces/" + owner.tenant) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) } func openStripeInvoice(t *testing.T, compose *environment.DockerCompose, subscription billedSubscription) string { t.Helper() resp, err := compose.StripeAPI(t.Context()). SetFormData(map[string]string{ "customer": subscription.customer, "subscription": subscription.id, "amount": "500", "currency": "usd", }). Post("/invoiceitems") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) invoice := struct { ID string `json:"id"` Status string `json:"status"` AmountDue int64 `json:"amount_due"` }{} resp, err = compose.StripeAPI(t.Context()). SetFormData(map[string]string{ "customer": subscription.customer, "subscription": subscription.id, "auto_advance": "false", }). SetResult(&invoice). Post("/invoices") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.Equal(t, "draft", invoice.Status) resp, err = compose.StripeAPI(t.Context()).SetResult(&invoice).Post("/invoices/" + invoice.ID + "/finalize") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.Equal(t, "open", invoice.Status) require.Positive(t, invoice.AmountDue) return invoice.ID } func voidStripeInvoice(t *testing.T, compose *environment.DockerCompose, invoice string) { t.Helper() resp, err := compose.StripeAPI(t.Context()).Post("/invoices/" + invoice + "/void") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) } type stripeTestClock struct { id string frozen time.Time } func newStripeTestClock(t *testing.T, compose *environment.DockerCompose) *stripeTestClock { t.Helper() frozen := wallClock.Now().Truncate(time.Second) created := struct { ID string `json:"id"` }{} resp, err := compose.StripeAPI(t.Context()). SetFormData(map[string]string{"frozen_time": strconv.FormatInt(frozen.Unix(), 10), "name": stripeTestClockName}). SetResult(&created). Post("/test_helpers/test_clocks") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) t.Cleanup(func() { resp, err := compose.StripeAPI(context.WithoutCancel(t.Context())).Delete("/test_helpers/test_clocks/" + created.ID) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) }) return &stripeTestClock{id: created.ID, frozen: frozen} } func (c *stripeTestClock) advance(t *testing.T, compose *environment.DockerCompose, by time.Duration) { t.Helper() c.frozen = c.frozen.Add(by) resp, err := compose.StripeAPI(t.Context()). SetFormData(map[string]string{"frozen_time": strconv.FormatInt(c.frozen.Unix(), 10)}). Post("/test_helpers/test_clocks/" + c.id + "/advance") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) require.EventuallyWithT(t, func(tt *assert.CollectT) { current := struct { Status string `json:"status"` }{} resp, err := compose.StripeAPI(t.Context()).SetResult(¤t).Get("/test_helpers/test_clocks/" + c.id) if assert.NoError(tt, err) && assert.Equal(tt, http.StatusOK, resp.StatusCode(), resp.String()) { assert.Equal(tt, "ready", current.Status) } }, testClockWaitFor, 5*time.Second) } func (c *stripeTestClock) advanceUntilCanceledForPayment(t *testing.T, compose *environment.DockerCompose, subscription string) int64 { t.Helper() current := struct { Status string `json:"status"` CanceledAt int64 `json:"canceled_at"` CancellationDetails struct { Reason string `json:"reason"` } `json:"cancellation_details"` }{} for range testClockSteps { c.advance(t, compose, testClockStep) resp, err := compose.StripeAPI(t.Context()).SetResult(¤t).Get("/subscriptions/" + subscription) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) if current.Status == string(models.BillingStatusCanceled) { require.Equal(t, "payment_failed", current.CancellationDetails.Reason) return current.CanceledAt } } require.FailNow(t, "Stripe did not cancel the subscription for a failed payment", "status %q after %d steps of %s", current.Status, testClockSteps, testClockStep) return 0 } func createStripeCustomer(t *testing.T, compose *environment.DockerCompose, tenant, testClock string) string { t.Helper() created := struct { ID string `json:"id"` }{} resp, err := compose.StripeAPI(t.Context()). SetFormData(map[string]string{ "test_clock": testClock, "email": tenant + stripeTestEmailDomain, "metadata[tenant_id]": tenant, }). SetResult(&created). Post("/customers") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return created.ID } func payOpenInvoices(t *testing.T, compose *environment.DockerCompose, subscription billedSubscription) { t.Helper() invoices := struct { Data []struct { ID string `json:"id"` Status string `json:"status"` } `json:"data"` }{} resp, err := compose.StripeAPI(t.Context()). SetQueryParams(map[string]string{"customer": subscription.customer, "subscription": subscription.id}). SetResult(&invoices). Get("/invoices") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) paid := 0 for _, invoice := range invoices.Data { if invoice.Status != "open" && invoice.Status != "uncollectible" { continue } resp, err := compose.StripeAPI(t.Context()).Post("/invoices/" + invoice.ID + "/pay") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) paid++ } require.Positive(t, paid, "the failed payment left no invoice to pay") } func deliverConcurrently(t *testing.T, compose *environment.DockerCompose, payloads ...[]byte) []int { t.Helper() statuses := make([]int, len(payloads)) errs := make([]error, len(payloads)) var wg sync.WaitGroup for i, payload := range payloads { wg.Go(func() { resp, err := compose.PostStripeWebhook(t.Context(), payload, compose.StripeSignature(payload)) if err != nil { errs[i] = err return } statuses[i] = resp.StatusCode() }) } wg.Wait() require.NoError(t, errors.Join(errs...)) return statuses } func activeStripeSubscriptions(t *testing.T, compose *environment.DockerCompose, customer string) int { t.Helper() subscriptions := struct { Data []struct { ID string `json:"id"` } `json:"data"` }{} resp, err := compose.StripeAPI(t.Context()). SetQueryParams(map[string]string{"customer": customer, "status": "active"}). SetResult(&subscriptions). Get("/subscriptions") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) return len(subscriptions.Data) } type stripeLeftover struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Created int64 `json:"created"` } func sweepStripeLeftovers(t *testing.T, compose *environment.DockerCompose) { t.Helper() before := wallClock.Now().Add(-stripeLeftoverAge).Unix() for _, testClock := range listStripeTestClocks(t, compose) { if testClock.Name == stripeTestClockName && testClock.Created < before { deleteStripeObject(t, compose, "/test_helpers/test_clocks/"+testClock.ID) } } for _, customer := range searchStripeCustomers(t, compose, fmt.Sprintf(`email~"%s" AND created<%d`, stripeTestEmailDomain, before)) { if strings.HasSuffix(customer.Email, stripeTestEmailDomain) { deleteStripeObject(t, compose, "/customers/"+customer.ID) } } } func listStripeTestClocks(t *testing.T, compose *environment.DockerCompose) []stripeLeftover { t.Helper() var all []stripeLeftover params := map[string]string{"limit": "100"} for { page := struct { Data []stripeLeftover `json:"data"` HasMore bool `json:"has_more"` }{} resp, err := compose.StripeAPI(t.Context()).SetQueryParams(params).SetResult(&page).Get("/test_helpers/test_clocks") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) all = append(all, page.Data...) if !page.HasMore || len(page.Data) == 0 { return all } params["starting_after"] = page.Data[len(page.Data)-1].ID } } func searchStripeCustomers(t *testing.T, compose *environment.DockerCompose, query string) []stripeLeftover { t.Helper() var all []stripeLeftover params := map[string]string{"query": query, "limit": "100"} for { page := struct { Data []stripeLeftover `json:"data"` HasMore bool `json:"has_more"` NextPage string `json:"next_page"` }{} resp, err := compose.StripeAPI(t.Context()).SetQueryParams(params).SetResult(&page).Get("/customers/search") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String()) all = append(all, page.Data...) if !page.HasMore || page.NextPage == "" { return all } params["page"] = page.NextPage } } func deleteStripeObject(t *testing.T, compose *environment.DockerCompose, path string) { t.Helper() resp, err := compose.StripeAPI(context.WithoutCancel(t.Context())).Delete(path) require.NoError(t, err) require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, resp.StatusCode(), resp.String()) }