#354

Add Stripe Billing emulation: subscriptions, invoices, test clocks and metered usage

Openenginerwants to merge intofrom

What

This extends @emulators/stripe from a payments emulator into a Stripe Billing emulator, so you can test a SaaS subscription integration end to end against emulate with no Stripe account and no network:

  • Webhooks as real Event objects: evt_ ids, /v1/events, /v1/webhook_endpoints (we_), per-endpoint delivery tracking, signed deliveries constructEvent accepts, and opt-in seeded delivery chaos (drop, duplicate, delay until flush, reorder), including across a whole test clock advance.
  • Payment methods and test cards: saved pm_ cards, attach and detach, customer default payment methods, the documented test-card table (declines, 3D Secure, 3D Secure 2, optional 3DS, already set up), PaymentIntent confirmation against card behaviour, and SetupIntents (seti_).
  • Invoices (in_), invoice items (ii_), invoice payments (inpay_): draft, finalize, pay, void, mark uncollectible, attempt counting, and the hosted invoice page.
  • Prices: recurring, tiered (graduated and volume) and metered prices.
  • Subscriptions (sub_) and subscription items (si_): trials (including trial end without a payment method), plan and interval changes, scheduled and immediate cancellation with cancellation_details, pausing and resuming, send_invoice collection, and Checkout in subscription mode. Billing periods live on the items (items.data[].current_period_start/end), as in current API versions.
  • Test clocks (clock_): POST /v1/test_helpers/test_clocks/:id/advance runs renewals, trial ends, invoice.upcoming, dunning retries and incomplete_expired in simulated time.
  • Dunning: failed renewal payments retried on a configurable schedule, then past_due, unpaid, canceled or paused after the last retry.
  • Usage-based billing: billing meters (mtr_), meter events with identifier deduplication, event summaries, and metered lines priced from aggregated usage, including final usage on cancellation.
  • Customer portal (bps_ sessions): an emulator HTML portal for cancel, undo cancel and card update, plus customer.subscriptions and the other includable customer fields through expand[].
  • Errors carry Stripe's type, code (resource_missing and so on), param and doc_url.
  • Seed config: api_version, webhooks, webhook chaos, billing settings (retry schedule, after-retries action), customers with cards, default payment methods and test clocks, products, meters and recurring prices. The emulate init template now seeds a monthly price.
  • Emulator-only controls under /_stripe/: webhooks/chaos, webhooks/flush, webhooks/deliveries, events/:id/redeliver, billing/settings, and simulate/ (3D Secure for payment and setup intents, saved-card behaviour).
  • Docs: the package README, the CLI help, the registry entry, the skill and the docs site.

Closes #324 Closes #325

Why

Billing is the part of a Stripe integration that is hardest to test. Renewals, trial ends, retries and metered invoices happen days or weeks later, and they reach your app only as webhooks. Stripe's test mode handles this with test clocks, but that needs an account, the network and real waiting for async webhooks. With this PR the same flows run in-process and deterministically: advance a clock, and the matching events have reached your webhook handler before the call returns.

How

  • Follows the package's existing layout: entities and store collections, one route module per resource, shared helpers in @emulators/core.
  • Object shapes, id prefixes, field names, null fields, enum values and status transitions follow the Stripe API reference for 2026-10-28.endive (the default api_version, which seed config can override). Wire nulls stay null.
  • Test clock advances run synchronously: Billing processes every due action up to the new frozen_time, then delivers that advance's events in order before the response returns.
  • A Differences from real Stripe section in the package README lists every deliberate simplification.

Simplifications vs real Stripe (full list in the package README)

  • Webhooks are delivered inline and in order before the response. There are no automatic delivery retries (only manual redelivery), one api_version for every event, no thin or v2 events, and livemode is always false.
  • Only cards exist, and only the documented test cards. There is no Radar, no exemptions, and 3DS is completed through an emulator route (next_action is always use_stripe_sdk). No SetupAttempts or mandates.
  • No taxes, discounts, coupons, credit notes, customer balance, shipping or Connect on invoices or subscriptions.
  • No prorations. An interval change bills the full new period, and updates always behave as payment_behavior=allow_incomplete. No subscription schedules, quotes, pause_collection, billing_cycle_anchor_config or thresholds. billing_mode is always flexible. All items of a subscription share one currency and interval.
  • Billing time passes only for customers on a test clock. Without one there are no background timers, so trials don't end, periods don't renew and retries don't run. Advances are synchronous (the stored clock is already ready), and clocks are never auto-deleted at deletes_after.
  • Retries follow a deterministic custom schedule (default 3/5/7 days). No Smart Retries, automations or overdue-invoice settings beyond send_invoice turning past_due.
  • Meter events are validated synchronously (400 instead of v1.billing.meter.error_report_triggered). Usage is summed per customer and meter at finalization. There are no v2 meter event streams.
  • The customer portal and hosted invoice page are emulator HTML with a test-card select. There is one default portal configuration, and the portal cannot change plans or quantities.
  • Prices and products have no update, search or delete API yet. Idempotency-Key is echoed but not replayed. Some error and validation messages are the emulator's own wording, and the README marks these.

How tested

  • 413 Vitest tests in packages/@emulators/stripe (22 files) cover each resource and SaaS scenarios end to end over HTTP: trial → paid → renewal → failed payment → retries → recovery or cancellation, metered billing, plan changes, pause and resume, portal flows, webhook chaos and test-clock edge cases. The 23 existing tests are unchanged and still pass.
  • The whole monorepo passes pnpm build, pnpm type-check, pnpm lint and pnpm format:check.
  • pnpm test passes for every package except two tests in packages/emulate (custom-config.test.ts and start-generated-secrets.test.ts, the "listener cannot bind" cases). They fail the same way without this branch on a macOS host where a wildcard :: listener does not block a 127.0.0.1 bind, so EADDRINUSE never comes. This PR doesn't touch them.

Credits

This builds on earlier work: PR #194 by Ansonhkg ("Add Creem and expanded Stripe billing emulation") and the Stripe billing work in the josh-vincent/emulate fork. Thank you both. The package README credits them too.

🤖 Generated with Claude Code

https://claude.ai/code/session_018UekYdEkB1b63LJ8zYagZH

Activity

46
vercel[bot]4d ago

@enginer is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

socket-security[bot]4d ago

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
npm/​stripe@​23.1.0

View full report

vercel-internal-security-reviewer[bot]4d ago
vercel[bot]reviewed4d ago
vercel[bot]4d ago

Additional findings

  1. packages/@emulators/stripe/src/subscriptions.ts:726-729 — Canceling a subscription while its renewal invoice is still a draft leaves automatic collection enabled, so advancing the clock charges the canceled subscription.

  2. packages/@emulators/stripe/src/routes/checkout-sessions.ts:384-392 — Payment-mode Checkout displays a different total from the amount charged when a price uses unit_amount_decimal, transform_quantity or tiered pricing.

  3. packages/@emulators/stripe/src/billing.ts:357-362 — Manually finalizing an auto-advancing invoice on a test clock leaves it open indefinitely because the scheduler excludes invoices without a previous payment attempt.

  4. packages/@emulators/stripe/src/invoicing.ts:398 — After the latest invoice is voided, settling the preceding unpaid invoice leaves the subscription past_due because it no longer matches latest_invoice.