Repository navigation
Hosted: billing correctness fixes from the review - #1420
Conversation
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>
Deploying mouseterm with
|
| Latest commit: |
5ecb757
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://55c7bb46.mouseterm.pages.dev |
| Branch Preview URL: | https://billing-review-fixes.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
|
On the policy date: keeping |
|
Understood — keeping 2026-10-28 is a deliberate call, and with billing dark no consent row can be ambiguous. Nothing further from my review. |
Fixes the correctness findings from a
/code-reviewof the whole billing range (#1405–#1414). Billing still ships dark.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.refundPaidInvoices), retried by the cron. Terms, Privacy, the closure dialog, and FAIL IFS290updated. Closure now shows billing's refusal message (e.g. a checkout still being created).PUT /api/billing/founderleave answers 204; the client hasjoinFounders/leaveFounders).accountRoutes/accountCronmounted by both prod and dev, sodor tool hostedgets the cohort routes and the cron.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), websitepnpm test(461), tsc, spec-lint, md-unwrap, lint:public-docs; new tests mutation-checked.🤖 Generated with Claude Code