Skip to content

Hosted billing: checkout, subscription entitlement, founding cohorts - #1001

Draft
nedtwigg wants to merge 11 commits into
alarm-upsellfrom
hosted-billing
Draft

nedtwigg wants to merge 11 commits into
alarm-upsellfrom
hosted-billing

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 4, 2026

Copy link
Copy Markdown
Member

Stacked on #998 (→ #1000 → #999 → #996). Checkout, the subscription as the entitlement, and founding cohorts, on @pgstencil/stripe 0.3.0 (diffplug/pgstencil#54).

Blocked on the pgstencil 0.3.0 release. The last commit, "TEMP: local pgstencil 0.3.0 tarball", vendors the packed tarballs, sets overrides, and marks the lockfile test as expected to fail. After releasing 0.3.0 from #54, drop that commit and run pnpm install.

Server (account Worker)

  • Plans and prices:
    • Plans are monthly, yearly and founding, with no trial and Managed Payments on.
    • Founding is offered at the open cohort's Price, and every past cohort's Price still grants access.
    • Price ids come from Worker bindings, never the browser.
  • Routes: /api/billing with checkout, confirm, portal, founder and survey, plus the Stripe webhook. The webhook takes the raw body, checks the signature, caps the body at 1 MiB, and answers 2xx only after commit.
  • Entitlement:
    • An active subscription (pgstencil status() rules) or the standing ADMIN_EMAIL comp.
    • Computed in SQL next to each bearer, so the Relay still resolves bearer and entitlement in one query.
    • A lapsed payer keeps their plan view and Manage billing.
  • Founding seats: GET /api/hosted/cohorts returns seats left and the founders row, with names only. It is also served on dormouse.sh/api/hosted/* through a pinned zone route.
  • Resync: subscriptions already past their period end are resynced every 10 minutes, in random order.
  • Off by default: billing answers 503 until all five Stripe bindings are set. Previews and the dev loop never bill for real.
  • Migrations and grants: 006_billing_founders.sql, and runtime-roles.sql grants for the Workers that read entitlement. verifyPackages now covers @pgstencil/stripe.

Account app

  • /checkout?plan=: signs in first, then shows the plan and today's price. A pending plan survives a provider sign-in.
  • /billing?checkout=: confirms the checkout, then shows the founders-row opt-in (unticked, with a chosen name) and the optional Van Westendorp survey.
  • Account → Plan: the current plan, Manage billing, and the founders toggle.
  • No email needed: accounts without one can buy, and Stripe Checkout collects it.
  • Dev loop: dor tool hosted bills against StripeDev.

Website

CHECKOUT_OPEN in hosted-pricing.ts switches the buy buttons between the "not wired up" notice and real checkout links, and the JSON-LD between PreOrder and InStock. It is committed as false. The seats line shows only while the endpoint's cohort matches FOUNDING_COHORTS_CLOSED.

Decisions to check

  • ADMIN_EMAIL stays a standing comp.
  • Refunds return seats only with an immediate cancel. A refund must also cancel the subscription immediately in Stripe for the seat to return; this is documented as an operator step.
  • Founding can oversell. Checkouts already open when a cohort fills may oversell it, as pricing.md accepts.
  • The open cohort never drops back to a cheaper step after a refund.
  • The cohort cache is per isolate.
  • Turning billing on admits subscribers to the Relay, so the spec gates it on the independent review that security-remote.md -> "Cloud-hosted mode" requires.

Test plan

  • Root pnpm test (website 347)
  • Hosted on Docker: 172 passed, plus the 1 expected lockfile failure
  • Deploy scripts 40/40; spec, md and public-docs lints
  • Monthly, yearly and founding flows end to end against StripeDev in agent-browser, including the portal and the cohorts endpoint
  • Stripe sandbox with real test keys and Managed Payments
  • A Workers route on dormouse.sh/api/hosted/* takes precedence over Pages

🤖 Generated with Claude Code

nedtwigg and others added 11 commits October 4, 2026 00:56
…on as the entitlement

The account Worker sells monthly, yearly and founding through
@pgstencil/stripe (named plans, no trial, Managed Payments): checkout,
the return's confirm, the customer portal, the founders-row opt-in, the
Van Westendorp answers, and Stripe's signed webhook. Price ids come from
Worker vars; billing is off (503) until all five Stripe bindings are set,
and a founding ladder whose length differs from the published one fails.

The entitlement predicate is now an active subscription under status()
semantics, resolved in SQL beside each bearer, plus the verified
ADMIN_EMAIL as a standing comp. The relay and voice roles read only the
subscription columns it needs.

GET /api/hosted/cohorts answers the open cohort, its seats, and the
opted-in founders (names only), cached 60 s per isolate; the account
Worker also answers it, and nothing else, on dormouse.sh through a
pinned zone route. An hourly cron resyncs subscriptions whose period ends
within the hour, so a missed renewal webhook never lapses a member.

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

hosted.md gains "Billing" (routes, bindings, the founding offer, the
webhook, the cohort endpoint and its site route, the hourly resync) and
states the entitlement as the subscription plus the ADMIN_EMAIL comp.
pricing.md promotes what is built into "Checkout and entitlement" and
keeps the account pages, the site's buy links, turning billing on, and
founder avatars under Future. security-hosted.md gets a Billing boundary,
the site-origin exception, and the Stripe package in the provenance
checks; the Hosted audit prompt asks about billing.

The page now drops seats unless the endpoint's cohort is the one its
price was prerendered at, so a stale deploy never prints the next
cohort's seats beside the old price. The README lists the Stripe setup.

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

pgstencil's checkout now takes a null email (named-plans dab7292), so a
provider-only account or a GitHub login with a private address can buy:
the Stripe customer is created without one and Checkout asks the buyer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on StripeDev in the dev loop

`/checkout?plan=` signs in first, shows the plan and its price now (the
founding step from the server's open cohort), then hands off to Stripe.
Stripe's return to `/billing?checkout=` confirms the checkout and shows
the welcome page: the founders-row opt-in, unticked, and the four Van
Westendorp questions, sent only when the buyer sends them. The account
page gains a Plan section with Manage billing and the founders toggle.
A pending checkout's plan name rides provider sign-in in session storage.

`dor tool hosted` now bills against StripeDev (no card, no charge), and
the dev guard admits StripeDev's one cross-site navigation back to
/billing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`CHECKOUT_OPEN` in hosted-pricing.ts decides both what a buy button does
(the unbuilt-checkout notice, or a link to the account origin's
/checkout?plan= by checkout's plan names) and the offers' JSON-LD
availability (PreOrder or InStock). It ships false. The founding card
promises a name, not an avatar, in the founders row.

The specs promote the account pages and the buy links; desktop sign-in
and turning billing on stay under Future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- entitlement.ts owns the access rule (`accessSql`) and the subscribed
  check (`subscribedSql`, one aggregate instead of two subqueries); the
  founders row and the resync reuse them, so the founders row now also
  requires exactly one current subscription, as the entitlement does.
- The account summary reads status, entitlement, founder and cohort on
  the one Billing pool and no longer calls Stripe per view; confirm
  builds it inside its own Billing.
- The test bundle's injected Date already scopes the system clock, so the
  clock parameter through accountApp is gone; `scheduled` runs in the
  same scope. The cohort cache keeps text, never a Response.
- Plan names live once in hosted-pricing.ts (tier ids are checkout's
  plans); the return path once in policy-constants; StripeDev's billing
  setup once in billing-dev.ts; the 413 body limit once in account-gate.
- The account app derives one page and writes the address from it, and
  the account frontend's billing calls go through `request`.
- Previews rebuild for any website/src/lib/hosted-* change.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd, every 10 minutes

- A subscription Stripe still holds (past_due, unpaid, incomplete) keeps
  its plan on the account page with Manage billing and "a payment is
  due", rather than "No plan" and a checkout that refuses; `active` says
  whether it grants access.
- The resync now runs every 10 minutes on subscriptions still active or
  trialing past their period or trial end (a resync before the renewal
  read the old period), picking at random so one account that always
  fails cannot starve the rest.
- The welcome page shows only for a completed checkout; a pending plan
  rides a provider sign-in for 10 minutes and once.
- Spec rules lead with Must/May; cleanups in billing-dev.ts and style.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…where it landed

pricing.md takes hosted-signin's front door, sign-in surface, managed
voice pointer and open question, and drops "the subscription as the
entitlement" from its Future and Status. hosted.md's Future no longer
lists the entitlement swap, and the hand-minted token routes are no
longer "the admin test path". ADMIN_EMAIL's comment names the standing
comp instead of "until billing ships".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop this commit once pgstencil 0.3.0 (diffplug/pgstencil named-plans,
packed clean from dab7292) is released to npm, then run `pnpm install`
and commit the lockfile: the code commits already declare ^0.3.0.

vendor/ holds the three archives, pnpm-workspace.yaml overrides the
three package names to them (and lets the file: override satisfy their
pgstencil peer), and the lockfile test is marked as expected to fail
while they are vendored.

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

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: cc2075a
Status: ✅  Deploy successful!
Preview URL: https://5007d763.mouseterm.pages.dev
Branch Preview URL: https://hosted-billing.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.

openCohort can reopen a cheaper founding step, which breaks pricing.md -> "Never reopen the ladder at a lower step once a cohort has closed". The step only stays closed after someone has bought at the next one. Take a cohort that has just filled and has no purchase yet at the next step, so sold = [100, 0, …]. If one of its buyers refunds within REFUND_DAYS and cancels, sold becomes [99, 0, …], last is 0 again, and checkout offers the cohort-0 Price. A deploy after the close has already prerendered the cohort-1 price. The page then drops the seats line because the cohorts don't match, while checkout charges the old price. The test at billing.test.ts ([full - 1, 2, …]) covers the refund only after the next cohort has a sale.

One fix: decide whether a step is closed from gross completed purchases, with no refundDays exclusion, and take seats left from the net count. A refunded seat still returns to its own cohort, but that cohort can't become the open one again. hosted.md -> "Billing" describes the open-cohort rule in a way that needs the same change.

Comment thread hosted/server/billing.ts
sold.forEach((count, step) => {
if (count > 0) last = step;
});
const cohort = (sold[last] ?? 0) >= FOUNDING_COHORT_SIZE ? last + 1 : last;

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.

With sold = [FOUNDING_COHORT_SIZE - 1, 0, …] (a refund in a cohort that just closed, before any sale at the next step), last is 0 and this reopens cohort 0. The doc comment above says that can't happen.

This branch is waiting to be deployed

1 waiting deployment
hosted-preview — cc2075af Waiting Oct 4, 2026 by nedtwigg via deploy #1038
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