Skip to content

Hosted: billing correctness fixes from the review - #1420

Merged
nedtwigg merged 4 commits into
mainfrom
billing-review-fixes
Oct 29, 2026
Merged

nedtwigg merged 4 commits into
mainfrom
billing-review-fixes

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Fixes the correctness findings from a /code-review of the whole billing range (#1405–#1414). Billing still ships dark.

  • Disputes outlive the account (migration 019 drops the dispute FK): a dispute on a closed account used to fail every charge.dispute.* delivery. The account cron now rereads open disputes from Stripe, least recently checked first, so a missed close event can't suspend an account for good; a lost one ends its subscription, a won one records its restoration notice once.
  • Founding seats: the open cohort is the last step anyone bought at, until its net count fills, then the next. A refund in the open cohort now returns its seat; the counter no longer overstates.
  • Closing inside the 30-day guarantee refunds that payment in full (owner decision): the first payment of the subscription, or a yearly/founding renewal paid in the last 30 days, through the withdrawal refund path (refundPaidInvoices), retried by the cron. Terms, Privacy, the closure dialog, and FAIL IF S290 updated. Closure now shows billing's refusal message (e.g. a checkout still being created).
  • Leaving the founders row works with billing closed (PUT /api/billing/founder leave answers 204; the client has joinFounders/leaveFounders).
  • Fewer Stripe reads: the billing page caches the customer contact for 10 minutes per isolate (withdrawals and mail read fresh); confirmed Prices are cached.
  • Notices that keep failing go to the back of the batch instead of starving newer ones.
  • Dev parity: one accountRoutes/accountCron mounted by both prod and dev, so dor tool hosted gets the cohort routes and the cron.
  • Shared cohort work runs under waitUntil, so a client that goes away can't cancel I/O other requests are awaiting.

Legal wording for review (Terms closure paragraph, Privacy closure records and "payment disputes still open", closure dialog, the security guarantee row).

Judgment calls: closure refunds each subscription's own first payment (so a resubscribed account is refunded too), matching pgstencil's refund-window seat count, slightly beyond the guarantee's "first payment". A refund after a cohort fills but before anyone buys at the next step reopens the cheaper step; checkout reads the live price, so the deployed page can briefly show the higher one.

Tests: hosted pnpm test (297), website pnpm test (461), tsc, spec-lint, md-unwrap, lint:public-docs; new tests mutation-checked.

🤖 Generated with Claude Code

nedtwigg and others added 4 commits October 28, 2026 18:20
A dispute on a closed account hit the dispute table's foreign key to the
deleted user, so every charge.dispute.* delivery failed and a lost
dispute's end never ran. Migration 019 drops it, as the other billing
records have none (it also adds the closure's settledAt, which closure
refunds use in a later commit). The cron now rereads a bounded batch of
open disputes from Stripe, least recently checked first, so a missed
close event no longer suspends an account for good: a won one restores
and records its notice once, a lost one ends its subscription before its
row goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
openCohort closed a cohort on its gross count but reported seats from its
net count, so a refund in the open cohort never freed a seat: 99 bought
and 4 refunded showed 5 left and closed after 1. The open cohort is now
the last step anyone bought at while its net count is short of full,
else the next; an earlier step stays closed once a purchase landed after
it, so a refund there never reopens the cheaper price.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…founders row needs no billing

Owner decision: closing an account within 30 days of a subscription's
first payment, or of a yearly renewal payment, refunds that payment in
full, as the guarantee would. A first payment is judged by
purchaseStanding's refund window, so the seat a closure frees is now one
it refunded; a monthly renewal is never refunded. The closure records a
settledAt once its refunds are done; the refunds reuse the withdrawal's
idempotent refund (now refundPaidInvoices), and the cron retries a
failed one for 30 days. The closure dialog, the Terms' closure
paragraph, Privacy, pricing.md, hosted.md, and S290 say so.

Billing's own refusal during closure, a checkout still being created,
now answers its status and message instead of a generic 500/503.

Leaving the founders row ran inside billed(), so it failed while billing
was closed or misconfigured though the spec says leaving always works.
It now deletes the row without billing and answers 204; the page reads
its plan again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cohort work kept alive

- The billing page reuses a customer's contact Stripe gave for 10
  minutes, per isolate, instead of reading it on every load in a
  subscription's first 21 days; a withdrawal and every email still read
  it fresh. A price change's Prices, once confirmed, are not read again:
  Stripe never changes a Price's amount. Both caches key on a Stripe
  client now kept one per key for the isolate's life.
- sendNotices takes never-tried notices first, then the least recently
  tried, so notices that keep failing no longer starve newer ones past
  their deadlines.
- The account Worker and the dev server mount one accountRoutes and run
  one accountCron, so dev gains the founders row, terms acceptance, the
  admin metrics, and the cron it lacked.
- The cohort snapshot and avatar loads that later requests share run
  under the starting request's waitUntil, so a client going away cannot
  cancel I/O other requests are waiting on.

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

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

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5ecb757
Status: ✅  Deploy successful!
Preview URL: https://55c7bb46.mouseterm.pages.dev
Branch Preview URL: https://billing-review-fixes.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.

This PR changes the Terms closure paragraph and two Privacy paragraphs, but TERMS_VERSION in hosted/server/policy-constants.ts and the "Last updated" date in website/src/components/HostedPolicyLayout.tsx both still say 2026-10-28. That is the version main already serves. docs/specs/hosted.md -> "Terms acceptance" says TERMS_VERSION is the policy pages' revision date and changes with every terms revision. As things stand, a sign-in acceptance or a dormouse_checkout_consents row recorded against 2026-10-28 could mean either the old closure wording ("does not by itself refund") or the new automatic refund, so the consent record can't tell you which text someone agreed to. Bump both dates to this revision's date; hosted/server/tests/policy.test.ts keeps them in step. The new text only favours the customer, so if you decide this edit doesn't count as a revision, that's a legitimate call, but it should be made on purpose.

@nedtwigg

Copy link
Copy Markdown
Member Author

On the policy date: keeping 2026-10-28 is deliberate. The previous closure wording merged today (#1413), so a new revision can't carry a later date without dating it in the future. Billing is still dark, so no dormouse_checkout_consents row can exist against the old wording. And the change only favours the customer: an automatic refund where the old text promised none. If this merges on a later day, I'll bump TERMS_VERSION and the "Last updated" date then.

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Understood — keeping 2026-10-28 is a deliberate call, and with billing dark no consent row can be ambiguous. Nothing further from my review.

@nedtwigg
nedtwigg merged commit b2dc3a8 into main Oct 29, 2026
18 checks passed
@nedtwigg
nedtwigg deleted the billing-review-fixes branch October 29, 2026 02:38
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