Skip to content

Hosted: billing routes, webhook, refunds and disputes - #1406

Merged
nedtwigg merged 2 commits into
hosted-billing-corefrom
hosted-billing-routes
Oct 29, 2026
Merged

nedtwigg merged 2 commits into
hosted-billing-corefrom
hosted-billing-routes

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Billing PR 2 of 4, stacked on #1405 (billing core). Billing stays closed: every billing route answers 503 "Checkout is not open yet." until the STRIPE_* bindings are set.

  • Routes (hosted/server/billing-routes.ts): GET /api/billing, and POST checkout, confirm and portal. The login gate runs first, and bodies are capped at 4 KiB.
    • Checkout takes only a plan and a ref (one of HOSTED_REFS, other, or none), passed to pgstencil's checkout(..., { ref }). A Price, customer, owner, quantity or return URL never comes from the request.
    • Stripe gets the login's email only when it is verified, otherwise null, and Checkout collects one. email is added to AccountLogin.
    • An open Checkout for another plan is expired first.
  • Webhook: the body is capped at 1 MiB, the signature is verified before any write, and it answers 2xx only after commit (503 otherwise, so Stripe retries).
  • Refunds and lost disputes end only the subscription the refunded or disputed charge paid for. The pinned API (2026-08-26.dahlia) has no charge.invoice, so the charge is resolved through invoice payments to invoice.parent.subscription_details.subscription. The cancel happens in Stripe after the commit, retried on each delivery until it's gone. A charge that paid for an earlier subscription, or that can't be resolved, cancels nothing and logs a warning for the operator. A partial refund does nothing.
  • Open disputes suspend without terminating. The status is read from Stripe, not the event. An open dispute adds a row to the new dormouse_billing_disputes table (migration 014), which the entitlement checks. Won, closed or prevented deletes the row. Inquiries (warning_*) don't suspend.
  • Metrics:
    • checkout.started [plan:ref] is best effort.
    • checkout.completed [plan:ref], subscription.canceled and subscription.refunded [plan] are counted inside the webhook transaction, so a redelivery never counts twice.
    • Each in-transaction count runs under its own SAVEPOINT, so a failed count rolls back alone and is logged, and the billing write still commits with 2xx.
    • The admin page shows only the labels that have counts.
  • Specs and Privacy:
    • hosted.md has a routes table, the rules and the new metrics rows.
    • pricing.md moves the refund and dispute rule above the fold, and the paid-launch dispute gate narrows to the customer notice, which PR 4 builds.
    • security-hosted.md gets FAIL IF S283 (route inputs), S284 (webhook, including that a failed count never fails the event) and S285 (refunds and disputes), and widens S282 and S110.
    • Privacy covers dispute state, the checkout ref and the new counts.
  • Tests: a Stripe fake over StripeDev adds dispute retrieval, subscription cancel and invoice payments, which StripeDev doesn't model. Every new test is mutation-checked.

StripeDev wiring in dev.ts comes with PR 3, which adds the /billing page that needs it.

🤖 Generated with Claude Code

nedtwigg and others added 2 commits October 28, 2026 04:57
GET /api/billing, POST checkout, confirm, and portal on the account
Worker, behind the login gate, 4 KiB bodies, and 503 while billing is
closed. Checkout takes a plan and a ref only: the ref is one of
HOSTED_REFS, "other", or none, sent through pgstencil's checkout ref;
the owner is the login, its email only once verified, else null so
Stripe Checkout asks. A checkout open for another plan is expired.

The webhook verifies the raw body (1 MiB cap) before any write and
answers 2xx only after commit. In its transaction, each under its own
savepoint, it counts checkout.completed from completedCheckout and
subscription.canceled or .refunded from a purchase's end, so a
redelivery counts nothing and a failed count rolls back alone, logged.

A full refund or a lost dispute ends the subscription its charge paid
for, found through the PaymentIntent's invoice payments, while it is
the owner's current one; a charge for an earlier subscription or none
ends nothing and is logged for the operator. It runs after the commit,
retried on every delivery until it has. An open dispute, read from
Stripe's current status, suspends through a new
dormouse_billing_disputes row the entitlement checks; closing in the
account's favor restores it.

checkout.started counts best effort. The admin page lists only the
counted labels of a long label list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hosted.md gains the billing routes table, the webhook, refund, and
dispute rules, the dispute clause of the entitlement, and four metric
rows, with the webhook's counts under their own savepoint. pricing.md
promotes the refund and dispute rule above the fold and narrows the
paid-launch dispute gate to the notice. security-hosted.md adds S283
(route inputs), S284 (webhook), S285 (refunds and disputes) and widens
S282 and S110 to the disputes table. Privacy lists the dispute state,
the checkout ref, and the new counts. The Hosted audit prompt asks
whether billing can grant what Stripe did not sell.

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: df85b3d
Status: ✅  Deploy successful!
Preview URL: https://7288a2db.mouseterm.pages.dev
Branch Preview URL: https://hosted-billing-routes.mouseterm.pages.dev

View logs

@nedtwigg
nedtwigg added this pull request to stack #1416 October 28, 2026 19:57
@nedtwigg
nedtwigg merged commit 464b191 into main Oct 29, 2026
23 of 25 checks passed
@nedtwigg
nedtwigg deleted the hosted-billing-routes 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