Billing module
Stripe subscriptions: Checkout Session creation, customer-portal redirect, webhook
signature verification + event-dedup + subscription mirror to D1, a web-side
pricing/upgrade page + useSubscription() hook, the gated sample page at /members,
and a narrative story-test suite (PT-15) — self-contained under its modules/ roots
(plus the documented touch-points below) so the module can be removed without touching
unrelated code. Depends on the account module (PT-13’s requireUser and delete-account
cascade); nothing else depends on billing.
Gated sample page (PT-15)
Section titled “Gated sample page (PT-15)”apps/web/src/modules/billing/GatedSamplePage.tsx, mounted at /members, is the
example site’s living proof that account + billing compose end-to-end — the one page
worth re-checking after every merge. It renders one of three states driven by
useUser() + useSubscription(), gated strictly on subscription status (see “Known
gaps” below for why it does not also gate on emailVerified):
- Signed out — a register/sign-in prompt, no subscription check performed.
- Signed in, not subscribed (
statusnot in{active, trialing}) — a paywall with a “Subscribe” CTA wired to the existingstartCheckout()flow (same asPricingPage). - Subscribed (
active/trialing) — the sample premium content.
The gate is client-side only — do not copy it for real premium content
Section titled “The gate is client-side only — do not copy it for real premium content”GatedSamplePage decides what to render from useSubscription(), which reads
GET /api/billing/subscription. That call is authenticated (requireUser), so an
unauthenticated visitor genuinely cannot learn a subscription’s status — but the
content it gates is a static string compiled into the client bundle. Anyone can read
it out of the shipped JS, or flip the state in a debugger, without ever paying. That is
fine here (the “premium content” is a placeholder sentence with no value) and it is what
keeps the page a pure front-end demo, but it is not an access-control boundary.
A stamped project putting real paid content behind this page must serve that content
from the worker, from a route that re-checks the subscription server-side — i.e. a
handler behind requireUser that calls getSubscription() and 403s on a non-active/
trialing status — and have the page fetch it. Treat the three client states as
presentation only; the server route is the gate.
It is registered in apps/web/src/App.tsx (/members) and in the main nav
(apps/web/src/components/Layout.tsx, alongside Home/Blog) rather than only being
reachable via the pricing/account flow — it’s meant to be one click away for anyone
checking the example site still works, not buried behind a purchase.
SEO: registered but noindex
Section titled “SEO: registered but noindex”/members is registered in apps/web/src/seo.config.ts with noindex: true (a new
RouteMeta field, see packages/shared/src/seo-types.ts) rather than left out of the
registry entirely. Registering it means it still gets prerendered with its own real
<head> and stays SPA-routable on a hard refresh (the worker’s generated
spa-routes.generated.ts is built from the same registry); noindex: true makes
renderHeadTags() emit <meta name="robots" content="noindex" /> and makes
buildSitemapEntries() (packages/shared/src/sitemap-routes.ts) exclude it from
sitemap.xml — a gated page has nothing crawlers should index or list.
Narrative story-test suite (PT-15)
Section titled “Narrative story-test suite (PT-15)”tests/integration/ at the repo root — not under apps/worker/src/ — exercises the
real webhook handler and real applySubscriptionEvent/D1-mirror logic end-to-end for
each named scenario (mocking only the outbound Stripe HTTP calls, via
vi.stubGlobal('fetch', ...), same as every other billing test): subscribe-happy-path,
trial, cancel-at-period-end, cancel-immediate, refund, card-decline,
out-of-order-webhook, dispute, plus safety-guard.test.ts and cleanup.test.ts.
Scenario names and structure are original to this codebase, written from the ticket’s
scenario list and this module’s own webhook/state-machine code — no source was read
from poker-puzzle, the private repo that list was inspired by.
It lives at the repo root (not apps/worker/src/) so it reads as whole-system
narrative behaviour rather than one module’s unit tests, but it needs the same real
workerd + D1 environment apps/worker’s own tests already have configured — rather
than standing up a second vitest-pool-workers config, apps/worker/vitest.config.ts’s
test.include points at both src/**/*.test.ts and
../../tests/integration/**/*.test.ts, so pnpm --filter @template/worker run test
(and therefore pnpm check) runs them as part of the same worker test run. tests/
is a pnpm workspace member (tests/integration/package.json,
@template/integration-tests) purely so tsc/pnpm -r run typecheck can resolve
vitest/drizzle-orm/Cloudflare types from its own node_modules — it deliberately
has no test script of its own, so test execution only ever happens through
apps/worker’s vitest-pool-workers config above, never standalone (these tests import
cloudflare:test, which only exists inside that pool).
cancel_at_period_end — a second field alongside status
Section titled “cancel_at_period_end — a second field alongside status”The “cancel at period end” story needs to tell apart “still active, but scheduled to
cancel when the period ends” from an immediate cancellation — Stripe represents this as
a customer.subscription.updated event with status: "active" and
cancel_at_period_end: true (it does not emit the terminal .deleted/canceled event
until the period actually ends). subscriptions.cancel_at_period_end (migration
0003_stormy_the_watchers.sql) is a new boolean column alongside status for exactly
this — applySubscriptionEvent() now also carries it through
SubscriptionEventData.cancelAtPeriodEnd, and GET /api/billing/subscription exposes
it on the response.
charge.dispute.created and charge.refunded — deliberate no-ops
Section titled “charge.dispute.created and charge.refunded — deliberate no-ops”Neither event type is in SUBSCRIPTION_EVENT_TYPES, so the webhook route’s existing
“acknowledge and skip any other event type” behaviour already handles both without any
new code. This is called out explicitly rather than left implicit because both look
like they should do something at a glance:
charge.refundedis a charge-level event, not a statement about subscription status. A refund does not itself mean the subscription should be cancelled — Stripe only does that in response to a separate subscription-status event, which arrives as its owncustomer.subscription.updated/.deletedwebhook (already covered by the cancel story tests).charge.dispute.createdis deliberately not wired to auto-cancel the subscription. Auto-cancelling on a dispute would let a webhook payload alone revoke access — including on a fraudulent dispute — without a human ever reviewing it. The correct response to a dispute is a human-reviewed process (a stamped project would wire this to an ops alert), not an automatic D1 mutation.
tests/integration/refund.test.ts and tests/integration/dispute.test.ts assert this
no-op behaviour directly against the real webhook route.
Stripe live-key safety guard
Section titled “Stripe live-key safety guard”apps/worker/src/modules/billing/stripe-client.ts’s stripeRequest() — the single
choke point every exported function (createStripeCustomer, createCheckoutSession,
createPortalSession) calls through — now throws LiveModeKeyError before making any
network request if the secret key starts with sk_live_. This template has no real
Stripe account and every test mocks fetch rather than hitting Stripe’s live API, so
nothing here has ever exercised a real live-mode key; this is a static safety net
against a future misconfiguration (e.g. a stamped project’s local .dev.vars
accidentally pointing at a production secret), asserted in
tests/integration/safety-guard.test.ts with a fake sk_live_... string — no real
Stripe credentials needed.
Core architectural rule: D1 is the query surface
Section titled “Core architectural rule: D1 is the query surface”getSubscription(userId) (apps/worker/src/modules/billing/subscription.ts) reads
only from D1’s subscriptions table. The app never calls the Stripe API at request
time to answer “is this user subscribed?” — every read of subscription state, on both
the worker (GET /api/billing/subscription) and web (useSubscription()) sides, goes
through this D1 mirror. The only two places this module calls the Stripe HTTP API at
all are POST /api/billing/checkout (create a Checkout Session) and POST /api/billing/portal (create a customer-portal session) — both one-shot writes that
return a redirect URL, not reads of subscription state. Everything else about a user’s
subscription arrives asynchronously via the webhook and is mirrored to D1 before
getSubscription ever sees it.
Webhook signature verification
Section titled “Webhook signature verification”apps/worker/src/modules/billing/webhook-signature.ts implements Stripe’s signing
scheme directly (HMAC-SHA256 over ${timestamp}.${rawBody} with the webhook secret,
compared against the Stripe-Signature header’s v1 value via crypto.subtle.verify,
which is timing-safe internally) rather than skipping or stubbing it. This is the
entire security boundary for POST /api/billing/webhook: without it, anyone who found
the URL could POST a fake customer.subscription.updated event and grant themselves
(or revoke someone else’s) access. A t=<unix-seconds> tolerance window (5 minutes)
is enforced as replay protection — a captured signature+payload pair stops verifying
shortly after capture even if the secret never rotates. Verification runs against the
raw request body text (c.req.text()), never a re-serialized JSON.stringify of the
parsed body, because re-encoding can reorder keys or reformat numbers and would break a
genuine signature.
Event dedup
Section titled “Event dedup”stripe_events.event_id is the table’s primary key. claimEvent()
(apps/worker/src/modules/billing/events.ts) does a single INSERT ... ON CONFLICT (event_id) DO NOTHING RETURNING event_id and checks whether a row came
back — an atomic insert-or-skip, not a SELECT followed by a conditional INSERT.
That avoids the exact race PT-11/12’s review flagged for auth-token consumption: two
concurrent deliveries of the same event id (Stripe retries on anything but a 2xx
response) can’t both observe “not present yet” and both proceed to reprocess it, because
only one INSERT can ever win the unique constraint.
The claim is taken before the event is applied, which would make any mid-handler
failure permanent — Stripe’s retry would see the claim and skip the work, stranding the
subscription in a stale state. The webhook route therefore wraps the apply step and calls
releaseEvent() (deleting the claim) plus answers a 500 if it throws, so the retry is
treated as a first delivery.
State machine keyed on subscription status, not event arrival order
Section titled “State machine keyed on subscription status, not event arrival order”This is the requirement the ticket calls out explicitly, with the test case “out-of-order
(updated-before-created) converges to correct state.” Stripe does not guarantee webhook
delivery order — network retries and Stripe’s own delivery infrastructure mean a
“customer.subscription.updated” event can arrive at this endpoint before the
“customer.subscription.created” event that logically precedes it, even though Stripe
generated the created event first.
applySubscriptionEvent() (apps/worker/src/modules/billing/subscription.ts) handles
this by keying convergence on lastEventCreatedAt — the Stripe event envelope’s own
created field (when Stripe generated the event), stored on the subscriptions row —
rather than on the order events happen to arrive in:
- Every incoming event is compared against the row’s current
lastEventCreatedAt. - The row is only updated if the incoming event’s
createdtimestamp is later than what’s already stored. An event describing an earlier point in time than the already-applied state is a no-op, however it happens to have arrived. createdhas one-second resolution and Stripe emits several events for the same subscription within one second — cancelling emitscustomer.subscription.updatedandcustomer.subscription.deletedtogether — so equal timestamps are common and cannot be resolved by “strictly later wins” without falling back to arrival order. Ties are broken on status instead: a terminal status (canceled,incomplete_expired,unpaid) beats a same-second non-terminal one, and is never overwritten by it. Without that rule, anupdatedevent landing before its same-seconddeletedtwin would suppress the cancellation and leave a cancelled subscription reading asactive.
Concretely: if “updated” (event created = T2, status active) is delivered first, the
row is created with lastEventCreatedAt = T2. When “created” (event created = T1 <
T2, status incomplete) arrives second, T1 < T2 so it’s discarded — the row stays
active. A naive last-write-wins implementation keyed on arrival order would instead let
the second-arriving “created” event clobber the row back to incomplete, which is
exactly the failure this design avoids. apps/worker/src/modules/billing/routes.test.ts
(converges to the correct state when an updated event arrives before its created event) delivers the two events in that order to the real /api/billing/webhook route
and asserts the final D1 state is active, not incomplete — a naive
last-write-wins implementation would fail that specific assertion.
Customer linkage
Section titled “Customer linkage”customers (user_id primary key, unique stripe_customer_id) is populated by
getOrCreateCustomer() at checkout time — a Stripe Customer is created and its id
stored in D1 before the Checkout Session that references it, so every subscription
webhook that later arrives for that Stripe customer already has a row to resolve its
customer field back to a userId (subscription events carry no userId of their
own). If the webhook receives an event for a customer id with no matching row, it logs
a warning and skips the event rather than throwing — this is a defensive branch for a
customer created outside this app’s checkout flow (e.g. directly in the Stripe
dashboard), not an expected path in normal operation.
Touch-points
Section titled “Touch-points”apps/worker/wrangler.toml— the commentedSTRIPE_PRICE_IDvar placeholder and thewrangler secret put STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETdocumentation block. No real values are checked in anywhere in this template.apps/worker/src/env.ts—STRIPE_SECRET_KEY?,STRIPE_WEBHOOK_SECRET?(Worker secrets),STRIPE_PRICE_ID?(plain var).apps/worker/src/index.ts— importsbillingfrom./modules/billingand mounts it at/api/billing.apps/worker/src/db/schema.tsandapps/worker/src/db/index.ts—customers,subscriptions,stripeEventstables and their re-exports, alongside the account module’s tables.apps/worker/migrations/—0002_soft_shriek.sql, the generated migration for the three tables above, and0003_stormy_the_watchers.sql, which addssubscriptions.cancel_at_period_end.apps/worker/src/modules/auth/delete-account.ts—accountDeletionStatements()extended withdb.delete(subscriptions)...anddb.delete(customers)..., ahead of the existingsessions/authTokens/usersdeletes in the same atomicdb.batch().stripe_eventsis deliberately not included: it’s keyed by Stripe event id, not user id, and holds no personal data (a dedup ledger, not user-owned state).apps/worker/src/modules/billing/— all billing route/client/webhook/subscription module code.apps/web/src/App.tsx— importsPricingPage/GatedSamplePagefrom./modules/billingand mounts/pricingand/members.apps/web/src/components/Layout.tsx— the “Members” main-nav link to/members.apps/web/src/seo.config.ts— the/membersroute registration (noindex: true).apps/web/src/modules/billing/— all module code (api client,useSubscription()hook,PricingPage,GatedSamplePage).packages/shared/src/seo-types.ts/head-tags.ts/sitemap-routes.ts— theRouteMeta.noindexfield and its effect onrenderHeadTags()/buildSitemapEntries(). Shared by every module’s routes, not billing-specific, so removing billing does not mean reverting these — only the/membersroute entry that uses the field.tests/integration/— the PT-15 story-test suite and itspackage.json/tsconfig.json(a pnpm workspace member purely for type resolution, see above).apps/worker/vitest.config.ts— thetest.includeentry pointing at../../tests/integration/**/*.test.ts.pnpm-workspace.yaml— thetests/*package glob.scripts/stripe-tunnel.mjs/scripts/stripe-tunnel.ps1— local dev helper that runsstripe listen --forward-to <worker-url>/api/billing/webhookvia the Stripe CLI (not installed or invoked by this repo’s own tooling — the CLI and astripe loginare a separate, one-time developer setup step).
Removal steps
Section titled “Removal steps”- Delete
apps/web/src/modules/billing/. - Remove billing’s entries from
apps/web/src/modules.config.tsx: the/pricingand/membersroutes frommoduleRoutes, the/membersentry frommoduleNavLinks, and the now-unused import. - In
apps/web/src/seo.config.ts, remove the/membersroute entry. - In
apps/worker/src/index.ts, remove theimport { billing } from './modules/billing'line and theapp.route('/api/billing', billing)call. - Delete
apps/worker/src/modules/billing/. - Delete
tests/integration/and remove thetests/*entry frompnpm-workspace.yaml. - In
apps/worker/vitest.config.ts, remove the../../tests/integration/**/*.test.tsentry fromtest.include(or the wholeincludeoverride if nothing else needs it). - In
apps/worker/src/modules/auth/delete-account.ts, remove thedb.delete(subscriptions)anddb.delete(customers)statements (and the now-unusedcustomers/subscriptionsimports) fromaccountDeletionStatements(). - In
apps/worker/src/db/schema.tsandapps/worker/src/db/index.ts, remove thecustomers,subscriptions, andstripeEventstable definitions/exports — unless another module still reads D1 (the account module already does, sosrc/db/itself stays). - In
apps/worker/src/env.ts, removeSTRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, andSTRIPE_PRICE_ID. - In
apps/worker/wrangler.toml, remove the billing comment block and any deployedSTRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETsecrets (wrangler secret delete ...). - Delete
scripts/stripe-tunnel.mjsandscripts/stripe-tunnel.ps1. - A new Drizzle migration (
pnpm --filter @template/worker run db:generateafter step 9) will emitDROP TABLEstatements forcustomers/subscriptions/stripe_events— run it rather than hand-deleting0002_soft_shriek.sql/0003_stormy_the_watchers.sql, since earlier deployments may already have applied them. RouteMeta.noindex,renderHeadTags()’s robots-tag handling, andbuildSitemapEntries()’s noindex filter (packages/shared/) are not billing-specific — leave them in place even though nothing else currently uses them.- Delete this page (
apps/docs/src/content/docs/modules/billing.md) and the links to it from the modules index and the new-project checklist — a broken internal link fails the docs build. - Run
pnpm checkto confirm the rest of the suite is still green with the module gone.
Known gaps / deliberate deviations
Section titled “Known gaps / deliberate deviations”- No real Stripe account, keys, or network calls exist anywhere in this template —
by design for this ticket.
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETare documented as secret names only; every test mocksfetch(vi.stubGlobal('fetch', ...), the same conventionlib/email.ts’sResendSendertests use for Resend) rather than hitting Stripe’s real API. A stamped project provisions real keys viawrangler secret putand a real webhook endpoint/stripe listensession — neither is set up here. - One subscription per user, not one row per Stripe subscription id.
subscriptionsis keyed onuser_id(primary key), notstripe_subscription_id. This matches the ticket’sgetSubscription(userId)query surface and this template’s single-price offering; a project that sells multiple concurrent subscriptions per user would need to re-key this table onstripe_subscription_idwithuser_idas a plain indexed column instead. getOrCreateCustomer()is a check-then-create, not an atomic upsert — unlike the event-dedup path, this one still has a narrow race: two concurrentPOST /api/billing/checkoutcalls from the same user (e.g. a double-click before the first request’s redirect fires) could both observe “no customer row yet” and both call Stripe’sPOST /customers, creating two Stripe customers before the second D1 insert hits thecustomers.user_idprimary-key conflict and fails. The result is a wasted, orphaned Stripe customer (harmless in test mode, a minor Stripe-dashboard clutter item in production) rather than incorrect billing state — the second request’s checkout session still gets created against whichever customer id its own D1 insert orfindCustomerlookup ends up returning. Left as a documented gap rather than an atomic upsert here, unlike the event-dedup path, because the ticket’s atomicity callout was specifically about event processing (where a race causes double-applied side effects), not customer creation (where the worst case is a harmless duplicate record, not a security or correctness problem).checkout.session.completedis not handled. Onlycustomer.subscription.created/.updated/.deletedupdate the D1 mirror. This template creates the Stripe customer itself before Checkout (see “Customer linkage” above), so it does not needcheckout.session.completed’sclient_reference_id/customerfields to link a session back to a user — the customer row already exists by then. A project that instead lets Stripe auto-create the customer during Checkout would need to also handle that event to establish the link.- No idempotency key on the Checkout/Customer/Portal creation calls. A network
retry of
POST /api/billing/checkout(not a Stripe webhook — the outbound call this route itself makes) could create a duplicate Stripe Customer or Checkout Session if the first attempt’s response was lost after Stripe processed it. Stripe’s API supports anIdempotency-Keyheader for exactly this; not wired up here as it’s a hardening step beyond what the ticket’s Verify criteria call for (which cover the webhook-side idempotency/ordering guarantees, not outbound-call idempotency). PricingPage/GatedSamplePageboth treat any status in{active, trialing}as “subscribed”, everything else (includingpast_due,unpaid,canceled,incomplete, and an active-but-cancel_at_period_endsubscription) as “not subscribed.” This is a simplification for the example site — a production app usually wants a distinct “payment failed, update your card” state forpast_due/unpaid, and a “your access ends on<date>” state forcancel_at_period_end, rather than folding every non-active status into the same “please subscribe” CTA as a user who never subscribed at all.GatedSamplePagegates strictly on subscription status, notemailVerified. A prior security review foundemailVerifiedis currently decorative — nothing reads it (tracked as PT-39, not yet fixed). Gating the sample premium content on it as well would have meant inventing new unreviewed security-sensitive logic on top of a known gap rather than fixing PT-39 itself, soGatedSamplePagedeliberately gates on subscription status alone, exactly as the ticket’s three states describe.