Appearance
Testing Stripe
Two levels, and they answer different questions:
| Level | Needs | Proves | Does not prove |
|---|---|---|---|
| Without a Stripe account | nothing but this repository | The Worker's webhook handling, idempotency, state changes and billing error codes behave as documented | That Stripe accepts our requests or sends the payloads we expect |
| With a Stripe sandbox account | a Stripe account in test mode, the Stripe CLI | The real Checkout, Billing Portal and event flow | Live-mode behaviour, real card networks |
Status
Only the first level has been run. The sandbox Stripe account has not been created, so the second level and the manual QA checklist below have never been executed; they are the procedure to follow once the account exists.
Without a Stripe account
Unit and integration tests (vitest)
npm test runs the Worker inside the Cloudflare Workers test pool (@cloudflare/vitest-pool-workers) with in-memory D1 and R2. vitest.config.ts binds a fixed webhook secret and no Stripe key:
Binding in vitest.config.ts | Value |
|---|---|
STRIPE_WEBHOOK_SECRET | test-stripe-secret |
STRIPE_SECRET_KEY | empty (so the webhook never calls Stripe) |
STRIPE_PRICE_MONTHLY / _QUARTERLY / _ANNUAL | price_monthly_test / price_quarterly_test / price_annual_test |
HERMES_LINK_TOKEN | test-hermes-token |
test/stripe_webhook.test.ts builds events and signs them itself, with the same algorithm Stripe uses:
ts
async function signedHeader(body: string, secret: string, timestamp = Math.floor(Date.now() / 1000)) {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`));
const hex = Array.from(new Uint8Array(signature), (b) => b.toString(16).padStart(2, "0")).join("");
return `t=${timestamp},v1=${hex}`;
}What the file covers (all with STRIPE_SECRET_KEY empty, so payloads are applied as-is):
| Test | Covers |
|---|---|
| rejects bad signatures and stale Stripe timestamps | 400 invalid_signature for a wrong v1 and for t 600 s old (F11) |
| accepts signed subscription events, archives and deduplicates replay | customer.subscription.created → subscription row, connection pending; R2 archive at stripe/events/<id>.json; replay of the same event id → duplicate: true (F11) |
| uses subscription item periods when Stripe omits top-level periods | items.data[0] period fallback; /api/usage window (F11, F12) |
| keeps connected renewals and ignores stale events for replaced subscriptions | a renewal keeps connected; a different subscription id for a live account is recorded at stripe/duplicates/<id>.json; a later customer.subscription.deleted for it does not touch the live row (F11) |
| records a cross-account live inmate subscription as a duplicate | same inmate, other account → duplicate record, live row of the first account untouched (F11) |
| updates the plan from a changed subscription price even with stale metadata | Price id beats metadata.plan_id (F11) |
| returns a quarterly Hermes connection ... / current monthly window for quarterly allowances | monthly window derived from the Stripe period (F12, F13) |
Not covered by the test suite, because it requires a Stripe key and real responses: the outgoing Stripe calls in src/billing.ts (Checkout session creation, open-session lookup and expiry, portal session), the subscription re-fetch, and the cancellation of duplicates. Those are the reason the sandbox level exists.
End-to-end script (npm run e2e)
e2e/run.mjs drives a running Worker over HTTP, the same way a browser and Stripe would, and checks the whole chain including the Hermes bridge. Its full description is in Operations: Testing. Facts relevant to Stripe:
| Command | npm run e2e (or node e2e/run.mjs) |
| Prerequisite | npm run dev running with a .dev.vars whose values match the script's environment, and Python 3 for the bridge step |
| Environment variables | E2E_BASE_URL (default http://127.0.0.1:8787), E2E_HERMES_TOKEN (= HERMES_LINK_TOKEN), E2E_STRIPE_WEBHOOK_SECRET (= STRIPE_WEBHOOK_SECRET) |
| Stripe-related coverage | F11 signed-event simulation: create, replay (idempotency), bad signature, renewal update, deletion/cancel; F09 billing_not_configured answer from checkout; F07 inmate lock while subscribed |
The F09 billing_not_configured check can only pass while the Worker runs without STRIPE_SECRET_KEY and the STRIPE_PRICE_* secrets (that is the only condition under which the endpoint returns 503, see Checkout). Because that Worker has no Stripe key, the E2E run does not exercise the Stripe re-fetch either.
Simulating an event by hand
You can post a signed event to a local Worker without Stripe, which is what test/stripe_webhook.test.ts and the E2E script do in code. With the Worker running and STRIPE_WEBHOOK_SECRET set in .dev.vars:
Sign up, add an inmate, and read your user id from
GET /api/me(user.id).Save an event as
event.json(any uniqueid, and the user id inmetadata.user_id):json{ "id": "evt_local_001", "type": "customer.subscription.created", "data": { "object": { "id": "sub_local_001", "object": "subscription", "customer": "cus_local_001", "status": "active", "current_period_start": <NOW_UNIX_SECONDS>, "current_period_end": <NOW_PLUS_30_DAYS_UNIX_SECONDS>, "cancel_at_period_end": false, "metadata": { "user_id": "<USER_ID>", "plan_id": "monthly" } } } }Compute the header over the exact bytes of the file and post it:
shSIG=$(STRIPE_WEBHOOK_SECRET=whsec_or_any_secret node -e ' const c = require("crypto"), fs = require("fs"); const t = Math.floor(Date.now() / 1000); const v1 = c.createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET) .update(`${t}.${fs.readFileSync("event.json", "utf8")}`).digest("hex"); console.log(`t=${t},v1=${v1}`);') curl -sS -X POST http://localhost:8787/api/stripe/webhook \ -H "Stripe-Signature: $SIG" --data-binary @event.jsonUse the same secret as in
.dev.vars. Expected:{"received":true}; sending it again gives{"received":true,"duplicate":true}. Replace the two period placeholders with real Unix seconds (date +%sanddate -d '+30 days' +%s) so that now lies inside the period; otherwise/api/usagereports no window.Inspect the result: the console shows the subscription and the inmate as
pending;GET /api/hermes/connections?status=pending(with the Hermes bearer token) lists it. WithoutSTRIPE_SECRET_KEYthe payload is applied as-is.
Failure checks with the same setup: a wrong v1, or a t older than 300 s, gives 400 invalid_signature; an invalid JSON body signed correctly gives 400 invalid_event; unsetting STRIPE_WEBHOOK_SECRET gives 503 billing_not_configured.
With a Stripe sandbox account
Prerequisites: Setup steps 1-5 and 7 done (account, test mode, three Prices, secret key, Customer portal, .dev.vars), the Worker running with npm run dev, migrations applied (npm run db:migrate:local).
Forward webhooks with the Stripe CLI
Stripe cannot call localhost, so the Stripe CLI forwards events (general Stripe knowledge for the CLI syntax):
sh
stripe login
stripe listen --forward-to localhost:8787/api/stripe/webhookstripe loginopens a browser to link the CLI to the account (test mode).stripe listenprintsYour webhook signing secret is whsec_.... Put it intoSTRIPE_WEBHOOK_SECRETin.dev.varsand restartnpm run dev. It is different from the signing secret of any Dashboard endpoint, and it changes if you delete the CLI's session.- Leave it running. Each forwarded event is printed with the HTTP status the Worker answered;
200is expected. To limit the noise, pass--events checkout.session.completed,checkout.session.expired,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted(general Stripe knowledge); the Worker acknowledges and archives every other event type anyway.
A real payment
- Sign up, add an inmate, open Plan & billing and click Choose on a plan. The browser goes to Stripe Checkout.
- Pay with a test card (below), any future expiry, any CVC, any postal code.
- Stripe redirects to
/console/#billing?checkout=successand the console shows "Payment received." - In the
stripe listenoutput you should seecheckout.session.completedandcustomer.subscription.created(and, general Stripe knowledge, usuallycustomer.subscription.updatedand invoice events) answered200. - The inmate shows Connecting... (
pending) until the Hermes bridge acknowledges the connection.
Test cards
Stripe's test card numbers (general Stripe knowledge, from Stripe's testing documentation; use any future expiry date, any 3-digit CVC and any postal code):
| Card | Behaviour | What to check in the portal |
|---|---|---|
4242 4242 4242 4242 | Payment succeeds | Subscription active, connection pending |
4000 0000 0000 9995 | Payment is declined (insufficient funds) | Checkout shows the error; no subscription event arrives; the account stays unsubscribed; the family can retry in the same session |
4000 0025 0000 3155 | Requires 3-D Secure authentication | Complete the challenge: as for success. Fail the challenge: no active subscription; the account stays unsubscribed |
stripe trigger has limits
stripe trigger customer.subscription.created (and checkout.session.completed) creates synthetic fixture objects in your sandbox and sends their events (general Stripe knowledge). These objects know nothing about the portal:
- They have no
metadata.user_idand noclient_reference_id. - Their customer and subscription ids are not in the portal's
subscriptionstable.
So the Worker cannot attribute them to an account (findUserId in src/stripe_webhook.ts looks only at metadata.user_id, then the stored subscription id, then the stored customer id; checkout.session.completed needs client_reference_id or metadata.user_id) and ignores them: you will see 200 in stripe listen, the event id recorded in stripe_events and the raw event in stripe/events/<id>.json, and no change in subscriptions, connections or inmates. That outcome is itself a useful check of the ignore path, but it does not exercise the happy path. To exercise the happy path use a real Checkout payment, or the signed-event simulation above. The Stripe CLI can override fixture fields (--override resource:path=value, general Stripe knowledge, not tried here) which could add metadata.user_id, but a real Checkout remains the reference.
Where to look
| What | How |
|---|---|
| Subscription row, connection | npx wrangler d1 execute reform-assistant --local --command "SELECT user_id, plan_id, status, current_period_end, cancel_at_period_end, stripe_subscription_id FROM subscriptions" |
| Processed events | same with SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 20 |
| Archived events and duplicate records | the local R2 bucket under .wrangler/state (or npx wrangler r2 object get reform-assistant-files/stripe/events/<event_id>.json --local, general Wrangler knowledge) |
| Deliveries | stripe listen output; for a Dashboard endpoint, the endpoint's event log in the Dashboard |
| Replaying an event | the runbook (it requires deleting the event id from stripe_events first) |
Manual QA checklist
Run in a sandbox with stripe listen running. Feature IDs refer to the shared feature list. Tick each item only when it was actually observed.
| # | Feature | Steps | Expected |
|---|---|---|---|
| 1 | F09, F17 | Start the Worker with no Stripe values; click Choose | 503 billing_not_configured; notice "Payments are not switched on yet (sandbox setup pending)."; rest of the portal works |
| 2 | F09 | Fill .dev.vars; a new account without an inmate calls POST /api/billing/checkout | 400 inmate_required |
| 3 | F09 | With an inmate, choose Monthly, pay with 4242 4242 4242 4242 | Redirect to /console/#billing?checkout=success; notice "Payment received" |
| 4 | F11 | Right after #3 | stripe listen shows 200 for checkout.session.completed and customer.subscription.created; R2 has stripe/events/<id>.json for each; subscriptions.status = active, plan_id = monthly, period set; connections.status = pending; inmate pending |
| 5 | F13 | GET /api/hermes/connections?status=pending with the bearer token | The inmate is listed with plan_id: monthly, messages_per_month: 40 |
| 6 | F11 | Resend that customer.subscription.created event (Dashboard "Resend" or stripe events resend <evt_id>, general Stripe knowledge) | Worker answers 200; body has duplicate: true; no state change |
| 7 | F11 | curl -X POST http://localhost:8787/api/stripe/webhook -d '{}' with no signature | 400 invalid_signature |
| 8 | F09 | Call checkout again while subscribed | 409 already_subscribed; console notice "You already have an active plan." |
| 9 | F09 | Second account, same inmate_number, call checkout while the first is live | 409 inmate_taken |
| 10 | F09 | New account: Choose, then cancel on the Stripe page ("Back") | Redirect to ?checkout=cancel; "Checkout was cancelled. You have not been charged."; no events other than possibly checkout.session.expired later |
| 11 | F09 | New account: click Choose in two tabs; try to pay in the first tab | The first session is expired by the Worker (the Stripe page says it expired); only the second can be paid. checkout.session.expired clears the stored id |
| 12 | F09 | Pay with 4000 0000 0000 9995 | Declined in Checkout; no subscription; no customer.subscription.created |
| 13 | F09 | Pay with 4000 0025 0000 3155; complete and (separately) fail the challenge | Completed: as #3-4. Failed: no subscription |
| 14 | F10 | Account that never paid: click Manage billing | 404 no_customer; notice "No billing account yet. Choose a plan first." |
| 15 | F10 | Paid account: click Manage billing | Redirected to the Stripe Billing Portal; "return" brings you back to /console/#billing |
| 16 | F10, F11, F12 | In the portal switch Monthly → 3 months | customer.subscription.updated is 200; subscriptions.plan_id = quarterly (taken from the new Price id); /api/usage allowance stays 40 per month with billing_period_end extended |
| 17 | F10, F11 | In the portal cancel at period end | customer.subscription.updated; cancel_at_period_end = 1, status still active, connection still connected after the bridge ack |
| 18 | F11 | Cancel the subscription immediately (portal, Dashboard or CLI) | customer.subscription.deleted; status = canceled; connection not_connected; the bridge revokes the contact |
| 19 | F11 | Make a renewal fail (general Stripe knowledge: Dashboard test clocks plus card 4000 0000 0000 0341) | status past_due, connection not_connected; after a successful payment, status active and connection back to pending |
| 20 | F11 | Create a second subscription for the same customer in the Dashboard with metadata.user_id set to the account's id (general Stripe knowledge) | Worker records stripe/duplicates/<sub>.json and cancels that subscription in Stripe; the live subscription row is unchanged |
| 21 | F14 | Run the Hermes bridge against the local Worker | The pending connection is acknowledged connected (see Hermes bridge) |
| 22 | F11 | Stop the Worker, pay, restart stripe listen (events are retried or resent) | The retried events are applied once; a second delivery says duplicate: true |
Record the date and the Stripe API version of each run. Until this list has been run once, the status of the integration remains "code complete, untested against Stripe".