Skip to content

Hosted: checkout, billing confirmation, plan section, and founders - #1407

Merged
nedtwigg merged 6 commits into
hosted-billing-routesfrom
hosted-billing-app
Oct 29, 2026
Merged

nedtwigg merged 6 commits into
hosted-billing-routesfrom
hosted-billing-app

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Billing PR 3 of 4, stacked on #1406 (routes), which is stacked on #1405 (core). Billing stays closed until the STRIPE_* bindings are set.

  • Consent-gated checkout (FAIL IF S286). POST checkout requires terms === TERMS_VERSION and autoRenew: true (400; 409 for an outdated terms version). Before answering with the Stripe URL, it records a row in dormouse_checkout_consents (migration 016): the checkout, terms version, user, plan, Price, and time. The table has no foreign key, so the record survives account deletion.
  • Founders (FAIL IF S287). PUT /api/billing/founder writes dormouse_founders (migration 015). It inserts only while subscribedSql(owner, prices) holds on the founding Prices, and answers 409 otherwise; leaving always works. The name follows the 1–64 character, no-control-or-format rule. The public founders list and the website row come in PR 4.
  • GET /api/billing adds price, founder and consent.
  • Account app:
    • /checkout signs the user in, shows the plan and today's price, the disclosures, and the terms and auto-renew checkboxes, then hands off to Stripe. sessionStorage keeps the plan and the validated ref for 10 minutes, so a provider sign-in keeps the attribution.
    • /billing confirms the purchase and shows a durable confirmation, plus the unticked founders opt-in for founding buyers.
    • The account page gains a Plan section: plan, renews or ends, Manage billing (still offered on a held subscription so the card can be fixed), and the founders toggle.
    • NoPlanOffer links to /checkout while billing is open.
  • Dev: StripeDev is wired into dev.ts, so dor tool hosted can walk checkout end to end. Not verified yet: the local smoke run was blocked, so nobody has tried that end-to-end walk.
  • Specs: hosted.md, pricing.md (the built items move above the fold), security-hosted.md (S286, S287; next is 288), the audit prompt, Privacy, and the Hosted README. The README now says to turn on Stripe receipt emails, because the confirmation says Stripe sends them.

Legal wording for review:

  • hosted/src/Checkout.tsx: the disclosure list, both checkbox labels, "Continue to payment", and the Stripe/card note.
  • hosted/src/Plan.tsx: the confirmation rows, the status and lapsed copy, and the founders text.
  • Privacy: the two edited sentences.

Left for PR 4: the EU/UK withdrawal disclosure, account closure cancelling renewal (there is no deletion route yet), and the dispute customer notice.

🤖 Generated with Claude Code

nedtwigg and others added 4 commits October 28, 2026 05:13
Checkout now refuses to start without agreement to the current terms
version and to automatic renewal, and records both, with the plan and
Price it offered, before answering the Stripe URL
(dormouse_checkout_consents, kept as a billing record). PUT
/api/billing/founder joins or leaves the founders row
(dormouse_founders): joining needs a current founding subscription
(subscribedSql gains its prices argument) and a checked name; leaving
always works. GET /api/billing adds the price, founder state, and the
recorded consent. dor tool hosted now runs billing on StripeDev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/checkout signs in, then offers the plans on sale at today's price
(founding at the open cohort's step) with what each commits the buyer
to beside the purchase action, and starts Stripe Checkout only once the
buyer ticks agreement to the current terms version and to automatic
renewal. The plan and ref survive a provider's sign-in in sessionStorage
for ten minutes. /billing confirms the checkout Stripe returned from,
strips the query, and keeps a durable confirmation with the founders
opt-in, unticked. The account page gains a Plan section with Manage
billing, whose lapsed copy branches on entitled. With billing open, the
no-plan offer links to checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs row

hosted.md gains the founder route, the checkout's consent fields, the
consent and founders rules, the checkout's sessionStorage, and billing
in the dev loop. pricing.md moves sign-in-first checkout, consent at the
purchase action, the /billing confirmation, and the founders opt-in
above the fold; the paid-launch consent gate narrows to confirming
receipts meet each territory's durable-confirmation rule.
security-hosted.md adds FAIL IF S286 (consent before the Checkout URL)
and S287 (founders insert and name). Privacy covers consent records and
the chosen founders name; the README turns on Stripe receipts before
opening checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0d22653
Status: ✅  Deploy successful!
Preview URL: https://3cf65339.mouseterm.pages.dev
Branch Preview URL: https://hosted-billing-app.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The founding price on the renewal checkbox can disagree with the Price the consent row records. Checkout reads the price for the box ("renews automatically every year at $X") from billing.founding when the page loads. The server picks the founding Price later, when the request arrives: withBilling's founding resolver calls foundingCohort and returns setup.founding[open.cohort]. Suppose the open cohort sells its last seat while a buyer is on /checkout. The buyer ticks a box naming one step, Stripe opens at the next, and dormouse_checkout_consents stores that next Price next to a renewal consent the buyer gave at a different price. Stripe's page does show the real total, but the consent this PR records is meant to be the evidence (S286; pricing.md "Checkout and entitlement" asks for consent to automatic renewal with "the interval and renewal price" disclosed beside it, and says "the server records the consent with the Price offered").

One option that follows the existing pattern: the page sends the cohort (or price) it showed, and checkout answers 409 when the open cohort has moved, the same way TERMS_CHANGED handles an outdated terms version. If Stripe's own page counts as the disclosure, the record should say that instead. Either way the Legal reviewer should weigh in, since the PR already asks them to review this wording.

Separately, two comments still point to the section the consent and confirmation rules lived in before this PR. Suggestions below.

Comment thread hosted/src/Checkout.tsx Outdated
Comment thread hosted/src/Plan.tsx Outdated
The checkout page now sends the price it showed beside the renewal the
buyer agreed to. POST checkout expires a checkout whose Price
publishedPrice does not read as that price (a cohort sold out since the
page loaded, or a checkout left open at an earlier step) and answers 409
PRICE_CHANGED; the page rereads the price and asks for both agreements
again. The consent row records the Price the buyer saw, now the one
checkout opens at. S286, hosted.md, and pricing.md say so. Two comments
now point at pricing.md -> "Checkout and entitlement", not
"Paid-launch requirements".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg

Copy link
Copy Markdown
Member Author

Fixed the consent-price finding in 1691714. /checkout now sends the price it showed. After opening the checkout, the server compares that checkout's Price with it. On a mismatch it expires the checkout and answers 409 PRICE_CHANGED, recording no consent and counting no start, and the page re-reads the price and asks again with both boxes unticked. Comparing after opening also catches a checkout left open at an earlier founding step, which Stripe would otherwise reuse at the old Price. The consent row therefore always records the Price the buyer saw. S286, hosted.md and pricing.md state it, and a PriceChanged story covers the UI.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Thanks, 1691714 resolves it. Both sides read the same constants: planOffers takes LIST_MONTHLY, LIST_ANNUAL and FOUNDING_LADDER from website/src/lib/hosted-pricing.ts, and publishedPrice maps the opened checkout's Price to them. So a stale cohort, or a checkout left open at an earlier step, gets the 409 before any consent row is written. The consent row now inserts opened.price_id, which is the Price that was compared. The two stale Paid-launch requirements pointers are also fixed.

Entitled without a plan, or entitled on a lapsed plan, can only be the
ADMIN_EMAIL comp, so the copy says so and the stories are named for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit 464b191 into main Oct 29, 2026
18 checks passed
@nedtwigg
nedtwigg deleted the hosted-billing-app branch October 29, 2026 00:06
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.

2 participants