Skip to content

Testing Stripe ​

Two levels, and they answer different questions:

LevelNeedsProvesDoes not prove
Without a Stripe accountnothing but this repositoryThe Worker's webhook handling, idempotency, state changes and billing error codes behave as documentedThat Stripe accepts our requests or sends the payloads we expect
With a Stripe sandbox accounta Stripe account in test mode, the Stripe CLIThe real Checkout, Billing Portal and event flowLive-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.tsValue
STRIPE_WEBHOOK_SECRETtest-stripe-secret
STRIPE_SECRET_KEYempty (so the webhook never calls Stripe)
STRIPE_PRICE_MONTHLY / _QUARTERLY / _ANNUALprice_monthly_test / price_quarterly_test / price_annual_test
HERMES_LINK_TOKENtest-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):

TestCovers
rejects bad signatures and stale Stripe timestamps400 invalid_signature for a wrong v1 and for t 600 s old (F11)
accepts signed subscription events, archives and deduplicates replaycustomer.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 periodsitems.data[0] period fallback; /api/usage window (F11, F12)
keeps connected renewals and ignores stale events for replaced subscriptionsa 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 duplicatesame inmate, other account → duplicate record, live row of the first account untouched (F11)
updates the plan from a changed subscription price even with stale metadataPrice id beats metadata.plan_id (F11)
returns a quarterly Hermes connection ... / current monthly window for quarterly allowancesmonthly 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:

Commandnpm run e2e (or node e2e/run.mjs)
Prerequisitenpm run dev running with a .dev.vars whose values match the script's environment, and Python 3 for the bridge step
Environment variablesE2E_BASE_URL (default http://127.0.0.1:8787), E2E_HERMES_TOKEN (= HERMES_LINK_TOKEN), E2E_STRIPE_WEBHOOK_SECRET (= STRIPE_WEBHOOK_SECRET)
Stripe-related coverageF11 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:

  1. Sign up, add an inmate, and read your user id from GET /api/me (user.id).

  2. Save an event as event.json (any unique id, and the user id in metadata.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" }
      } }
    }
  3. Compute the header over the exact bytes of the file and post it:

    sh
    SIG=$(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.json

    Use 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 +%s and date -d '+30 days' +%s) so that now lies inside the period; otherwise /api/usage reports no window.

  4. 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. Without STRIPE_SECRET_KEY the 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/webhook
  1. stripe login opens a browser to link the CLI to the account (test mode).
  2. stripe listen prints Your webhook signing secret is whsec_.... Put it into STRIPE_WEBHOOK_SECRET in .dev.vars and restart npm run dev. It is different from the signing secret of any Dashboard endpoint, and it changes if you delete the CLI's session.
  3. Leave it running. Each forwarded event is printed with the HTTP status the Worker answered; 200 is 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 ​

  1. Sign up, add an inmate, open Plan & billing and click Choose on a plan. The browser goes to Stripe Checkout.
  2. Pay with a test card (below), any future expiry, any CVC, any postal code.
  3. Stripe redirects to /console/#billing?checkout=success and the console shows "Payment received."
  4. In the stripe listen output you should see checkout.session.completed and customer.subscription.created (and, general Stripe knowledge, usually customer.subscription.updated and invoice events) answered 200.
  5. 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):

CardBehaviourWhat to check in the portal
4242 4242 4242 4242Payment succeedsSubscription active, connection pending
4000 0000 0000 9995Payment 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 3155Requires 3-D Secure authenticationComplete 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_id and no client_reference_id.
  • Their customer and subscription ids are not in the portal's subscriptions table.

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 ​

WhatHow
Subscription row, connectionnpx 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 eventssame with SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 20
Archived events and duplicate recordsthe local R2 bucket under .wrangler/state (or npx wrangler r2 object get reform-assistant-files/stripe/events/<event_id>.json --local, general Wrangler knowledge)
Deliveriesstripe listen output; for a Dashboard endpoint, the endpoint's event log in the Dashboard
Replaying an eventthe 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.

#FeatureStepsExpected
1F09, F17Start the Worker with no Stripe values; click Choose503 billing_not_configured; notice "Payments are not switched on yet (sandbox setup pending)."; rest of the portal works
2F09Fill .dev.vars; a new account without an inmate calls POST /api/billing/checkout400 inmate_required
3F09With an inmate, choose Monthly, pay with 4242 4242 4242 4242Redirect to /console/#billing?checkout=success; notice "Payment received"
4F11Right after #3stripe 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
5F13GET /api/hermes/connections?status=pending with the bearer tokenThe inmate is listed with plan_id: monthly, messages_per_month: 40
6F11Resend 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
7F11curl -X POST http://localhost:8787/api/stripe/webhook -d '{}' with no signature400 invalid_signature
8F09Call checkout again while subscribed409 already_subscribed; console notice "You already have an active plan."
9F09Second account, same inmate_number, call checkout while the first is live409 inmate_taken
10F09New 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
11F09New account: click Choose in two tabs; try to pay in the first tabThe 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
12F09Pay with 4000 0000 0000 9995Declined in Checkout; no subscription; no customer.subscription.created
13F09Pay with 4000 0025 0000 3155; complete and (separately) fail the challengeCompleted: as #3-4. Failed: no subscription
14F10Account that never paid: click Manage billing404 no_customer; notice "No billing account yet. Choose a plan first."
15F10Paid account: click Manage billingRedirected to the Stripe Billing Portal; "return" brings you back to /console/#billing
16F10, F11, F12In the portal switch Monthly → 3 monthscustomer.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
17F10, F11In the portal cancel at period endcustomer.subscription.updated; cancel_at_period_end = 1, status still active, connection still connected after the bridge ack
18F11Cancel the subscription immediately (portal, Dashboard or CLI)customer.subscription.deleted; status = canceled; connection not_connected; the bridge revokes the contact
19F11Make 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
20F11Create 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
21F14Run the Hermes bridge against the local WorkerThe pending connection is acknowledged connected (see Hermes bridge)
22F11Stop 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".