feat(billing): Stripe subscriptions end to end (#768) - #875
Merged
Conversation
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
samxu01
force-pushed
the
feat/stripe-billing
branch
from
August 6, 2026 08:25
cf48b1d to
28d46e8
Compare
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three users want to pay. This wires the money path onto the entitlement seam that already existed —
entitlements.prowas 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
profrom the status Stripe reports, so out-of-order delivery converges instead of latching. Pinned by a revoke-then-regrant test.active/trialinggrant; 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().constructEventverifies 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 inserver.ts, and a test asserts the handler receives aBufferrather than a parsed object.The exit always works.
POST /billing/portalgives self-serve cancellation, because a user who cannot find the exit disputes the charge instead. Revoking Pro leavescloudAgentsuntouched — 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/settingsreading the same=== truecheck 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_SECRETmust 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-serverordering case that passes 4/4 in isolation (#518). Typecheck clean both sides.What this does NOT include, and needs you
https://api.commonly.me/api/billing/webhook, subscribed tocheckout.session.completedandcustomer.subscription.*.Recommended first run:
stripe listen --forward-toagainst 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_behavioris nowinclusive, and the preview settles at exactly $12.00 inevery 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_taxis off (Stripe Tax is not activated, andenabling 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
mainright now. That predates this branch — it shipped with thepricing 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); theentitlements.proconflict inmodels/User.tswas resolved by keeping bothcomments — main's BYO-agents-are-never-gated promise and this branch's
webhook-is-the-only-writer rule.