Appearance
Stripe billing
Stripe is how a family pays for Reform Assistant. This section is the complete reference for that integration: how to set up a Stripe account, what the Worker sends to Stripe, what it does with Stripe's events, how to test it, and how to go live.
Feature IDs used here are the shared ones (see the product feature map): F09 Checkout, F10 Billing portal, F11 Stripe webhook sync, F17 deployment secrets.
Status of the integration
- Code complete.
src/billing.ts(F09, F10),src/stripe_webhook.ts(F11) andsrc/plans.tsare implemented, with the webhook path covered bytest/stripe_webhook.test.ts. - The sandbox Stripe account has not been created yet.
- Nothing has been tested against real Stripe. The test suite simulates signed webhook events only; it never calls
api.stripe.com(the testSTRIPE_SECRET_KEYis empty). The outgoing calls insrc/billing.ts(Checkout and Billing Portal sessions) and the Stripe re-fetch and cancellation calls insrc/stripe_webhook.tshave never been executed against Stripe. Treat every page of this section as "implemented, unverified against Stripe" until the manual QA in Testing has been run in a sandbox.
What Stripe does here
| Job | Stripe object | Where in the code |
|---|---|---|
| Collect the first payment and start the subscription | Checkout Session, mode=subscription | createCheckout in src/billing.ts (Checkout) |
| Let the family change plan, update the card, or cancel | Billing Portal session | createPortal in src/billing.ts (Checkout) |
| Tell the portal that a subscription started, renewed, changed or ended | Webhook events | receiveStripeWebhook in src/stripe_webhook.ts (Webhooks) |
| Recurring charges, retries and receipts | Subscription and Price | Stripe itself; the portal never charges anything |
The Worker does not use a Stripe SDK. It calls https://api.stripe.com/v1/ with fetch and form-encoded bodies, authenticated with Authorization: Bearer <STRIPE_SECRET_KEY>, and verifies webhook signatures itself with Web Crypto. The portal stores no card data. Stripe owns the money; the portal's D1 database mirrors only the subscription state it needs (status, plan, period, customer and subscription ids).
Flow
text
family
| signs up, adds the inmate (#inmate)
v
/console #billing --- "Choose" on a plan card ---+
|
POST /api/billing/checkout {plan_id} | (F09)
<------------------------------------------------+
| Worker: validates, expires any older open session,
| POST https://api.stripe.com/v1/checkout/sessions
v -> {url}
browser is redirected to Stripe Checkout (hosted by Stripe)
| family pays
v
Stripe --- redirect ---> APP_URL/console/#billing?checkout=success
|
| webhook events (signed) (F11)
v
POST /api/stripe/webhook
| verify signature -> reserve event id (D1 stripe_events)
| -> archive raw event (R2 stripe/events/<id>.json)
| -> re-fetch subscription from Stripe -> upsert subscriptions row
v
D1: subscriptions.status = active|trialing
connections.status = pending inmates.connection_status = pending
|
v
Hermes bridge (cron) polls GET /api/hermes/connections?status=pending
| adds the Securus contact, then POST .../ack {status: connected|failed}
v
inmate is connected; replies start (usage reported back to the portal)Everything left of the webhook is initiated by the family's browser. Everything from the webhook down is driven only by Stripe's signed events: the redirect to ?checkout=success does not change any state, and the console only shows a notice for it.
Configuration at a glance
| Name | Kind | Value | Page |
|---|---|---|---|
APP_URL | var (wrangler.jsonc, default http://localhost:8787) | Public origin of the portal; builds the Checkout success and cancel URLs and the portal return URL | Checkout |
STRIPE_SECRET_KEY | secret | sk_test_... (sandbox) or sk_live_... | Setup |
STRIPE_PRICE_MONTHLY | secret | price_... for monthly ($8.00, every 1 month) | Setup |
STRIPE_PRICE_QUARTERLY | secret | price_... for quarterly ($21.60, every 3 months) | same |
STRIPE_PRICE_ANNUAL | secret | price_... for annual ($81.60, every 12 months) | same |
STRIPE_WEBHOOK_SECRET | secret | whsec_... of the webhook endpoint (or of stripe listen) | Setup |
If these are missing the rest of the portal still works and billing answers 503 billing_not_configured (see Checkout).
Pages
| Page | Read it when you want to |
|---|---|
| Setup | Create a Stripe account from scratch and wire the Worker to it (sandbox) |
| Checkout | Know exactly what POST /api/billing/checkout and /api/billing/portal do and which error codes they return |
| Webhooks | Understand signature checking, idempotency, event-to-state mapping, duplicates and retries |
| Testing | Test with no account (signed-event simulation, npm run e2e) or with a sandbox account (stripe listen, test cards), and run the manual QA checklist |
| Going live | Switch from sandbox to live keys and prices, and handle refunds for duplicate subscriptions |
Related pages outside this section: Cloudflare deployment (how secrets are set on the Worker), Hermes bridge (what happens after pending), API reference, Runbook.
Plans
The plan catalog lives in src/plans.ts and is served by GET /api/plans. Stripe's Prices must match it exactly, because Stripe, not the portal, decides what is charged.
| Plan id | Label | Amount | Stripe billing period | Messages per month |
|---|---|---|---|---|
monthly | Monthly | $8.00 | every 1 month | 40 |
quarterly | 3 months | $21.60 (10% off) | every 3 months | 40 |
annual | 12 months | $81.60 (15% off) | every 12 months | 40 |
The allowance is per month for every plan: a quarterly or annual subscription is billed once but the 40-message allowance renews each calendar month of the billing period (src/period.ts, feature F12).
Terminology note
Dashboard menu names, button labels and Stripe CLI flags mentioned in this section that are not visible in this repository come from general Stripe knowledge and may differ from what the Dashboard currently shows. They are marked (general Stripe knowledge) where they appear. Everything else on these pages was read from the code.