dánwòdánwò

Payments

Stripe billing

Subscriptions, setup intents, invoices and the billing portal, renewing on the emulator's clock so a test can jump a month ahead.

The Stripe emulator runs billing as Stripe does: subscriptions start, trials end, invoices are paid or fail, and every step sends its webhook. It all runs on the emulator's clock, so a test can travel in time and watch a renewal happen.

Set up

Billing comes with stripeService. Seed a recurring price for each plan:

danwo.config.ts
import { appEnvironment, defineConfig, stripeService } from "@danwo/sdk"

const url = "http://localhost:3000"

export default defineConfig({
  testDir: "tests",
  readiness: { mode: "page+backend", settle: true },
  environment: () =>
    appEnvironment({
      app: { command: "npm run dev", url },
      services: [
        stripeService({
          seed: () => ({
            products: [{ name: "Pro" }],
            prices: [
              {
                product_name: "Pro",
                unit_amount: 2000,
                currency: "usd",
                lookup_key: "pro_monthly",
                nickname: "Pro monthly",
                recurring: { interval: "month" },
              },
            ],
            webhookEndpoints: [
              {
                url: `${url}/api/webhooks/stripe`,
                events: ["customer.subscription.updated", "invoice.paid", "invoice.payment_failed"],
                secret: "whsec_test",
              },
            ],
          }),
        }),
      ],
    }),
})

lookup_key lets your app find the price by its own name, the way a plans table usually does.

Use it in a test

Subscribe through Checkout, jump a month ahead, and check the second invoice was paid:

tests/renewal.danwo.ts
import { type StripeFixtures, test as base } from "@danwo/sdk"

const test = base.extend<StripeFixtures>()

test("renews the plan after a month", async ({ app, screen, expect, time, settle, stripe }) => {
  await app.open("/pricing")
  await screen.getByRole("button", { name: "Choose Pro" }).click()
  await screen.getByTestId("checkout-pay").click()
  await expect(screen.getByText("Plan: Pro")).toBeVisible()

  await time.travel({ days: 32 })
  await settle()

  const answer = await stripe.fetch(
    new Request("https://api.stripe.com/v1/invoices?status=paid", {
      headers: { authorization: "Bearer sk_test_any" },
    }),
  )
  const { data } = await answer.json()

  expect(data).toHaveLength(2)
})

time.travel moves your app's clock and the emulator's together. settle waits until every webhook the renewal sent has reached your app.

Choose the outcome with a card

Stripe's test payment methods decide what happens to a payment:

Payment methodWhat happens
pm_card_visaSucceeds, now and at every renewal.
pm_card_chargeDeclinedDeclined, even when only saving the card.
pm_card_chargeCustomerFailSaves, but every charge fails: the renewal goes past_due.
pm_card_authenticationRequiredThe intent stops at requires_action (3-D Secure).

A past_due subscription stays that way until POST /v1/invoices/:id/pay succeeds.

What's covered

AreaCovered
Setup intentsSaving a card for later, with or without authentication.
SubscriptionsStart, change plan, cancel now or at the period's end, with trials.
StatusesEvery status Stripe uses, from incomplete to past due and canceled.
InvoicesRead and list them, pay one by hand, preview the next.
Billing portalPortal sessions, and a hosted page that cancels at the period's end.
CheckoutSubscription mode with a trial; setup mode, which saves the card.

Faults

The same faults as every Stripe call: 500, 429, a connection reset, and an answer lost after Stripe acted on a write. Each billing webhook can also arrive twice, late, forged or stale.

To test a failed renewal, attach pm_card_chargeCustomerFail from the test before travelling. See Time travel.

Webhooks

EventsSent when
customer.subscription.*A subscription is created, updated or deleted, and before a trial ends.
invoice.*An invoice is created, finalized, paid, fails, or needs the customer to act.
setup_intent.*A card is saved, fails to save, or needs authentication.
checkout.session.completedA subscription Checkout is paid.

Webhooks are signed on your app's clock, so a delivery after time travel isn't refused as stale.

Fidelity

Live. The Stripe suite includes setup intents, subscriptions and invoices, checked against a test-mode account. They match.

Not covered yet

  • Coupons, promotion codes and tax rates
  • Usage-based billing and meters
  • Subscription schedules and pausing

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI