Skip to content

Add example-stripe: subscription checkout with emulate + drizzle + vitest#47

Open
remorses wants to merge 8 commits into
mainfrom
example-stripe
Open

Add example-stripe: subscription checkout with emulate + drizzle + vitest#47
remorses wants to merge 8 commits into
mainfrom
example-stripe

Conversation

@remorses

@remorses remorses commented May 8, 2026

Copy link
Copy Markdown
Owner

Full Stripe subscription checkout example app using spiceflow, drizzle (SQLite), and the emulate Stripe emulator for e2e testing.

What's in it:

  • Pricing page, success page, dashboard with subscription status
  • GET /checkout/:orgId creates a Checkout Session and redirects to hosted checkout
  • Webhook handler with signature verification
  • Single customer per org pattern
  • Hardcoded price ID price_pro_monthly seeded in the emulator
  • 11 vitest tests covering the full flow: render pages → checkout → complete → webhook fires → DB updated → dashboard shows subscription

Emulator webhook quirk: the emulate package dispatches webhooks with X-Hub-Signature-256 (GitHub format) instead of stripe-signature. The webhook handler accepts both formats as a workaround. Tracked upstream: vercel-labs/emulate#98

remorses added 5 commits May 7, 2026 17:48
Add Simplify<T> that applies & {} only for non-scalar types (preserving
null, undefined, string, number, boolean, etc.) and wrap the FetchResult
usage inside ResolveResult so IDE hovers show the resolved data | error
union instead of nested conditional type aliases.

Session: ses_1fd3de59bffeb7u2hfIFs6aETI
Standalone "use server" action files (separate from the app entry) are
safe to use router.href() since they do not feed return types back into
typeof app. This gives full compile-time validation of redirect paths
and params against the route table.

README:
- "Redirecting After Actions" section now shows router.href() as the
  preferred pattern for standalone action files, with a concrete example
- Keeps the inline action example using plain string redirect (to avoid
  circular type issues when used inside app-entry handlers)
- Clarifies the circular-type details callout: standalone "use server"
  files are explicitly called out as safe to use router.href()

example-better-auth/src/actions.ts:
- redirect("/login") -> redirect(router.href("/login"))
- redirect(`/orgs/${id}/dashboard`) -> redirect(router.href("/orgs/:orgId/dashboard", { orgId: id }))
- redirect(`/orgs/${orgId}/dashboard`) -> redirect(router.href("/orgs/:orgId/dashboard", { orgId }))

example-forms/src/actions.ts:
- redirect(`/success?name=...&id=...`) -> redirect(router.href("/success", { name, id }))
  (router.href handles URL encoding, no more manual encodeURIComponent)

Session: ses_1f8467f19ffeO06UZBIiU4UN5R
Type-level changes:
- PathHasRequiredQuery: new helper type that detects required keys in
  query schemas using the {} extends T pattern
- ValidatedHref: accepts QS parameter, rejects bare string paths for
  routes with required query (forces using router.href() instead)
- HrefArgs/HrefArgsInner: require the query arg when schema has
  required fields, allow omission when all fields are optional
- QueryForPath: resolves pattern via MatchingPathPattern so resolved
  paths like /users/123 still enforce /users/:id query schema
- MergeParamsAndQuery: no longer wraps Q in Partial<>, keeping
  required query keys required when merged with path params
- LinkProps/NavigationTo: thread RouterQuerySchemas so Link and
  router.push/replace reject bare string paths with required query

Runtime changes:
- Page routes skip query validation (coerce only, no throw) so
  missing query params render the page instead of showing 422.
  API routes still return 422 for invalid query.

Tests:
- 20+ @ts-expect-error assertions for href, Link, router.push,
  router.replace, and createSpiceflowFetch
- Resolved dynamic path enforcement test (e.g. /users/123)
- API route 422 validation test
- e2e test: page with required query renders without error

Session: ses_1f836c98cffeedSRmMWpcf3gcd
…7.0.0.1

The previous approach defaulted server.host to 127.0.0.1, which made Vite
print http://127.0.0.1:5173 in the terminal instead of http://localhost:5173.

dns.setDefaultResultOrder(verbatim) prevents Node 17+ from reordering DNS
results to prefer IPv6, so localhost still resolves to 127.0.0.1 (IPv4).
This keeps the same port-collision protection while Vite shows the familiar
localhost URL.

Session: ses_1f7fce5d1ffex4MEG1SOaSqKMM
…e, vitest

Spiceflow app demonstrating a full Stripe subscription purchase flow
tested end-to-end with the emulate Stripe emulator.

**App routes:**
- GET / — pricing page
- GET /success — post-checkout confirmation
- GET /dashboard/:orgId — shows subscription status (loader + page)
- GET /checkout/:orgId — creates Stripe Checkout Session, redirects to
  hosted checkout. Hardcoded price ID (price_pro_monthly) seeded in
  the emulator. Redirects to dashboard if already subscribed.
- POST /api/portal — creates Billing Portal session
- POST /api/webhooks/stripe — webhook handler with signature verification
- GET /api/org/:orgId — returns org + subscriptions (used by tests)

**Stripe patterns:**
- Single customer per org via getOrCreateStripeCustomer()
- Lazy Stripe SDK client (reads STRIPE_HOST/PORT/PROTOCOL env vars on
  first call so tests can point at the emulator)
- Idempotent subscription upserts keyed on (subscriptionId, variantId)
- Webhook signature verification supporting both Stripe stripe-signature
  (production) and X-Hub-Signature-256 (emulate emulator)

**Test setup:**
- app.listen(0) for ephemeral port
- createEmulator({ service: "stripe" }) with seed data for product,
  price, and webhook endpoint (including secret for HMAC signing)
- Emulator delivers webhook on checkout complete; test polls until
  subscription appears in DB

**Known limitation:** the emulate Stripe emulator dispatches webhooks
with X-Hub-Signature-256 (GitHub format) instead of stripe-signature
(Stripe format). The webhook handler accepts both. Tracked upstream:
vercel-labs/emulate#98

Session: ses_YUiOcLKIjF
@vercel

vercel Bot commented May 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
integration-tests Ready Ready Preview, Comment May 8, 2026 5:52pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26194761dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread spiceflow/src/vite.tsx
server: {
host: userConfig.server?.host ?? '127.0.0.1',
},
server: {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep pages reachable on 127.0.0.1

When server.host is left unset, Vite falls back to localhost (its documented default), and on hosts where localhost resolves to ::1 first the dev server binds only IPv6; Node then refuses http://127.0.0.1:<port>. I checked integration-tests/playwright.config.ts starts dev with just pnpm dev --port ..., while integration-tests/e2e/basic.test.ts still fetches 127.0.0.1 directly, so those dev e2e checks can fail on common Linux/CI host ordering. Keeping the previous IPv4 default, or updating the tests/server config together, avoids this split.

Useful? React with 👍 / 👎.

Comment on lines 2264 to 2267
context.query = await runValidation(
coerceQueryWithSchema(context.query, route?.route?.hooks?.query),
route?.route?.validateQuery,
isPageRoute ? undefined : route?.route?.validateQuery,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve page query schema output

Skipping validateQuery for every page means handlers now receive only the raw/coerced URL object instead of the schema's parsed value. For example, a page query like z.object({ page: z.coerce.number().default(1) }) previously gave query.page === 1 when omitted, but now the handler sees undefined despite the TypeScript type; transforms, defaults, enum/refinement rejection, and other Standard Schema output are also bypassed. If the goal is only to avoid 422 pages for missing required params, this needs a narrower fallback so successful/defaulted parses still feed the handler.

Useful? React with 👍 / 👎.

- enforce required query params at compile time across all typed APIs
- simpler typed fetch hover types
- fix IsBodyRequired for all-optional body schemas
- dev server shows localhost instead of 127.0.0.1
- use 127.0.0.1 instead of localhost in webhook URL to avoid IPv6
  resolution issues on Linux + Node 18+
- use random emulator port (14000-14999) to avoid conflicts when
  multiple CI jobs share a runner
- increase poll timeout from 5s to 15s for slow CI environments

Session: ses_1f76364feffeiL5oKLWrda39Tu
…izeDeps

Vite discovered these deps at runtime in the RSC environment, triggering a
program reload that raced with the Cloudflare worker module runner. The stale
chunk reference caused "file does not exist" errors. Pre-including them
avoids the mid-startup discovery and reload.

Session: ses_1f76364feffeiL5oKLWrda39Tu
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant