Billing (Stripe)
flare gen billing adds Stripe billing to an app: subscription plans with
upgrades, downgrades and cancellation, plus one-time purchases. Payment pages
are Stripe-hosted (Checkout and the Customer Portal), so no card data ever
touches your app.
npx flare gen billing --provider stripe --mode subscriptions--mode subscriptions is the only mode, and it includes one-time checkout.
What it generates
Section titled “What it generates”| Resource | Holds |
|---|---|
Plan |
One row per Stripe Price. A monthly or yearly price is a subscription plan; a one-time price is something to buy once (interval empty). |
Customer |
Who pays: the Stripe customer and subscription ids, subscriptionStatus, subscriptionEndsAt, cancelAtPeriodEnd and the current planId. |
Purchase |
One row per one-time Checkout, keyed by its session. |
If the app already has a Customer resource, the billing fields are added to
its fields block and its own fields are kept. A hand-edited block is refused
unless you pass --force.
Policies make billing records admin-only for writes: Customer and
Purchase can be read by admin and staff; Plan by any signed-in user.
Subscription state therefore only changes through the signed webhook, never
through the REST API. You can edit the generated policy files.
It also generates:
lib/stripe.ts: the Stripe client, customer linking, and the webhook synclib/billing.ts: what the billing page readsapp/api/billing/checkout,app/api/billing/portal,app/api/webhooks/stripecomponents/billing/billing-button.tsxandbilling-portal-button.tsx(<BillingPortalButton>)app/dashboard/billing/page.tsx: subscribe, switch plan, manage/cancel, buy once- a migration for the three tables
- Keys. Put a Stripe test key in
.dev.varsasSTRIPE_SECRET_KEY. Prefer a restricted key (rk_test_…) with write access to Customers, Checkout Sessions and the Customer portal, and read access to Subscriptions, Products and Prices. Never commit keys; in production runwrangler secret put STRIPE_SECRET_KEY. - Tables.
npx flare migrate(and--remotefor production). - Catalog. Create one Product per plan in Stripe, with a price for
each billing variant (monthly, yearly, one-time). Give each Product the
metadata
flare_app=<your app's package name>. Optionally addslug(the plan’s URL name) andsort(display order). - Sync.
npx flare billing:sync-plans(add--remotefor production). It imports only this app’s tagged Products, so several apps can share one Stripe account.--allimports every active Product. - Webhook. Point a Stripe webhook endpoint at
https://<your app>/api/webhooks/stripewith these events:checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,customer.subscription.paused,customer.subscription.resumed,invoice.paid,invoice.payment_failed. Store its signing secret withwrangler secret put STRIPE_WEBHOOK_SECRET. Locally,stripe listen --forward-to localhost:8787/api/webhooks/stripeprints a secret for.dev.vars.
Webhooks are required, not optional. Renewals, failed payments and cancellations all happen after checkout, and the webhook is the only way the app learns about them.
How it behaves
Section titled “How it behaves”Checkout takes a plan slug, never a raw Stripe price, so a client can’t pay for an arbitrary price. The plan decides the mode: a recurring price starts a subscription, a one-time price a payment. A customer who already has a subscription is refused a second one (409) and changes plans instead. Checkout shows whatever payment methods are enabled in your Stripe dashboard.
Plan changes open the Customer Portal’s confirmation page for the chosen
plan. Stripe shows the prorated amount and changes the existing
subscription, so switching never charges twice. billing:sync-plans
maintains a portal configuration for this app that allows switching between
its synced plans and cancelling at the end of the period.
The webhook verifies Stripe’s signature with the Web Crypto API (Workers
has no synchronous HMAC), so forged, tampered and stale events are refused.
Each event re-fetches the subscription or session from Stripe instead of
trusting the payload, so duplicate and out-of-order deliveries all end at
the same, current state. A late event about an old, ended subscription can’t
replace a newer live one. These rules live in @flaredev/core (billing.ts)
and are unit-tested.
Statuses. subscriptionStatus is one of none, incomplete,
trialing, active, past_due, paused, canceled or unpaid. Use
grantsAccess(status) from @flaredev/core to decide whether paid features are
on (active, trialing, and past_due while Stripe retries).
isLiveSubscription(status) tells you whether the customer still has a
subscription to manage rather than buy again.
Cancelling in the portal cancels at the end of the period: the status
stays active and the billing page shows “ends on …”. When the period ends,
Stripe sends customer.subscription.deleted and the status becomes
canceled.
One-time purchases create a Purchase marked paid once the payment has
settled. That’s immediately for cards, or later for delayed payment methods
through checkout.session.async_payment_succeeded.
Verified
Section titled “Verified”scripts/e2e-billing-stripe.mjs runs the whole loop against a deployed app
and a Stripe test account: subscribe with the 4242 test card, upgrade in the
portal, cancel in the portal, the subscription ending, and a one-time
purchase. After each step it checks both the generated page and Stripe’s own
records. scripts/e2e-billing-offline.mjs covers the policies, webhook
signatures and the page without a Stripe account.