Skip to content

feat(billing): Stripe subscriptions end to end (#768) - #875

Merged
lilyshen0722 merged 3 commits into
mainfrom
feat/stripe-billing
Aug 6, 2026
Merged

feat(billing): Stripe subscriptions end to end (#768)#875
lilyshen0722 merged 3 commits into
mainfrom
feat/stripe-billing

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Three users want to pay. This wires the money path onto the entitlement seam that already existed — entitlements.pro was already the single gate the product reads; Stripe now writes it.

Correctness rules, each one because the obvious alternative loses money or gives it away

The webhook is the only source of truth. Nothing in the checkout success path grants anything — the redirect is attacker-controllable and a session can be abandoned after it. Only a signature-verified event moves the flag.

Idempotent by durable marker. Stripe retries for up to three days and guarantees neither once-only delivery nor ordering. BillingEvent's unique index is the lock, and it is claimed before the user is touched — a test asserts that ordering, because a crash between the two would apply a change with no record and the retry would apply it twice.

Entitlement is derived from status, never toggled. Every handler recomputes pro from the status Stripe reports, so out-of-order delivery converges instead of latching. Pinned by a revoke-then-regrant test. active/trialing grant; everything else revokes.

Checkout grants only when money settled (payment_status === 'paid'). A completed session with an async or failed payment is not money.

Raw body, mounted before express.json(). constructEvent verifies the exact bytes — a re-serialized body fails verification, which is the single most common way this integration breaks. Follows the existing Discord raw-body pattern in server.ts, and a test asserts the handler receives a Buffer rather than a parsed object.

The exit always works. POST /billing/portal gives self-serve cancellation, because a user who cannot find the exit disputes the charge instead. Revoking Pro leaves cloudAgents untouched — separate entitlements, pinned both directions.

Non-2xx means retry, so ignored events still return 200. Only an unrecorded handler failure returns 500. Otherwise a bug in our handling becomes a three-day retry storm we caused ourselves.

UI

A v2 Plan panel on /v2/settings reading the same === true check the backend gates on, so it can never show a tier the API would refuse. It never writes the flag: after checkout a user may briefly still read Free, and the copy says so rather than faking optimistic state. Buttons carry the .v2-root button. prefix — the global reset has eaten bare-class buttons twice already (#867, #870). en/zh key parity asserted.

Ships inert

All three of STRIPE_SECRET_KEY, STRIPE_PRICE_ID, STRIPE_WEBHOOK_SECRET must be set, or every billing route returns 503 rather than half-working. Documented in .env.example. Merging and deploying this changes nothing until the keys exist.

Verification

43 new tests — 21 service, 14 route, 8 panel. Backend suite compared against a same-commit baseline (not a stale one): identical failure set except one known-flaky mongodb-memory-server ordering case that passes 4/4 in isolation (#518). Typecheck clean both sides.

What this does NOT include, and needs you

  1. Stripe account + a $12/month recurring Price — I don't create accounts or handle payment credentials.
  2. The three secrets into GCP Secret Manager, synced via ExternalSecret like every other credential. Never in the frontend bundle.
  3. A webhook endpoint registered at https://api.commonly.me/api/billing/webhook, subscribed to checkout.session.completed and customer.subscription.*.
  4. Terms of service and a refund policy the checkout links to, plus a tax decision (Stripe Tax is a setting, not code).

Recommended first run: stripe listen --forward-to against a test key before any live key exists, and consider serving the first three customers by hand — five customers are faster to serve manually than to automate, and you learn more.


Update — tax behaviour and a promise we had to remove

The Stripe price is now tax-inclusive. It was billing $12 plus tax, so an
Australian customer saw $13.20 against a landing page that says $12. The price
tax_behavior is now inclusive, and the preview settles at exactly $12.00 in
every jurisdiction. The price id is unchanged, so no env var moves.

Worth knowing what that costs: inclusive means we absorb the tax where the sale
is taxable. In a 10% GST jurisdiction we net $10.91 instead of $12. In
California — where SaaS is not taxable — the computed tax is $0.00 and we keep
the full $12. Since automatic_tax is off (Stripe Tax is not activated, and
enabling it would fail session creation), nothing is calculated today either
way. The setting is made now because it is the half that cannot be changed
later without moving the sticker price.

The landing page advertised the paid tier as free. The Pro card carried a
"Free in beta" / "公测期免费" badge directly above "$12/human/mo", in both
locales, on main right now. That predates this branch — it shipped with the
pricing rewrite in #874 and would have gone live alongside the first real
charge. Removed, with a test that fails if any priced tier describes itself as
free again.

Base was refreshed onto main (#874 landed after this branch was cut); the
entitlements.pro conflict in models/User.ts was resolved by keeping both
comments — main's BYO-agents-are-never-gated promise and this branch's
webhook-is-the-only-writer rule.

lilyshen0722 and others added 3 commits August 6, 2026 01:20
Three users want to pay, so this wires the money path onto the entitlement
seam that already existed. `entitlements.pro` was already the single gate the
product reads; Stripe now writes it.

**The webhook is the only source of truth.** Nothing in the checkout success
path grants anything — the redirect is attacker-controllable and a session can
be abandoned after redirect. Only a signature-verified event moves the flag.

**Idempotent by durable marker.** Stripe retries for up to three days and
guarantees neither once-only delivery nor ordering. `BillingEvent`'s unique
index is the lock, claimed BEFORE the user is touched (test asserts the
ordering): a crash between the two would otherwise apply a change with no
record, and the retry would apply it twice.

**Entitlement is DERIVED from subscription status, never toggled.** Every
handler recomputes `pro` from the status Stripe reports, so out-of-order
delivery converges instead of latching — pinned by a revoke-then-regrant test.
`active`/`trialing` grant; everything else revokes.

**Checkout grants only when money settled** (`payment_status === 'paid'`). A
completed session with an async or failed payment is not money.

**Raw body, mounted before express.json().** `constructEvent` verifies the
exact bytes; a re-serialized body fails verification, which is the single most
common way this integration breaks. Follows the existing Discord pattern in
server.ts, and a test asserts the handler receives a Buffer.

**The exit always works.** `POST /billing/portal` gives self-serve
cancellation. A user who cannot find the exit disputes the charge instead. And
revoking Pro leaves `cloudAgents` untouched — separate entitlements, pinned.

**UI** — a v2 Plan panel on /v2/settings reading the same `=== true` check the
backend gates on, so it can never claim a tier the API would refuse. It never
writes the flag; after checkout the user may briefly still read Free, and the
copy says so rather than faking optimistic state. en/zh parity asserted.

Ships INERT: all three of STRIPE_SECRET_KEY / STRIPE_PRICE_ID /
STRIPE_WEBHOOK_SECRET must be set or every billing route returns 503 rather
than half-working. Documented in .env.example; keys belong in Secret Manager.

43 new tests (21 service, 14 route, 8 panel). Backend suite compared against a
same-commit baseline: identical failure set apart from one known-flaky
mongodb-memory-server ordering case that passes 4/4 in isolation (#518).
…ession

The Stripe price is now tax_behavior: inclusive, so a customer pays exactly
the advertised $12 in every jurisdiction rather than $12 plus whatever their
region adds. Without that setting the Australian preview billed $13.20 against
a landing page promising $12.

automatic_tax stays off: Stripe Tax is not activated on the account and
enabling it would fail session creation. Inclusive pricing is the half that
cannot be changed later without moving the sticker price, so it is set now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
The badge shipped with the pricing rewrite and survived the commit that turns
real billing on, so the page currently advertises the paid tier as free in
both locales ("Free in beta" / "公测期免费") directly above "$12/human/mo".
Charging someone who signed up under that banner is a dispute, not a
misunderstanding.

Removed rather than reworded: the accent border already marks the featured
tier, and every short badge available for a paid plan is either a price
promise we would have to keep or a popularity claim we cannot substantiate.
The absolutely-positioned rule went with it.

Guarded by a test asserting no tier that shows a non-zero price describes
itself as free. Scoped to the cost-describing fields only — the feature
bullets legitimately say "Everything in Cloud free", which names a tier
rather than making a price claim. Verified by reintroducing the badge and
watching both locales fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant