feat(cloud): wire the dunning lifecycle, read-only mode and lifecycle email (0418) - #673
Conversation
…illing, dunning and UI Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev>
Adds `writesEnabled` to PlanEntitlements and enforces it in the hub, so the non-payment lifecycle's gentlest step (grace lapsed -> read-only) has something to actually pull. Reads, exports and the billing routes keep working. Two invariants, both tested: - Absent means enabled. verifyEntitlements normalizes a missing field to true so tokens signed before this existed keep working; only an explicit false blocks. - Self-host never sees false. No HUB_PLAN means the field is never consulted. Gated in two places because HTTP alone would be theatre: a Hono middleware for mutating routes, and NodeRelayService for the sync socket, which is where the change log — the primary write path — actually arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev>
The timer half of the non-payment state machine had no caller: reconcileBilling was written, tested and inert, so a failed payment opened grace and nothing ever closed it. This connects it. - reconcile/billing-driver.ts executes decided actions against the control plane. Every transition notifies; a failed notice means the step is NOT applied, so the tenant stays put and the next tick retries. pending_deletion notifies BEFORE recording, because a final notice nobody received is worse than a clock that never started. - ControlPlane gains setBillingState, setWritesEnabled (live setEnv flip, no data movement) and reactivateTenant, which restores writes before waking a cold hub so it does not boot read-only. - billing/notify.ts adds the first mail transport in the repo. A tenant with no resolvable address THROWS rather than skipping — we do not degrade an account we cannot warn. Grace stays deliberately silent; Stripe already emailed. - stageExportBundle records a dated retention hold on the encrypted R2 replica. It cannot build a .xnetpack: the hub's export needs a UCAN signed by the user's DID and we hold no such key, which is the property worth keeping. - delete ships behind XNET_CLOUD_DUNNING_DELETE_ENABLED, and the control plane refuses to boot with it armed and no mail transport configured. reconcileInputFor exists because DunningState says while BillingReconcileInput says ; the obvious spread compiles and makes every healthy tenant look eligible for reactivate. A test caught it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: xNet Test <test@xnet.dev>
backupsConfigured was Boolean(env.R2_BUCKET) — a bucket name reported as if it were a working backup. BackupHealth keeps off/unproven/healthy/failing distinct, derived from the last restore drill, and unproven maps to null on the public status page rather than true: a bucket nobody has restored from is not a promise we have earned. Also: - drillSampleSize scales the nightly sample with the fleet instead of a constant 20, which drilled a 3-tenant fleet repeatedly and a 500-tenant fleet at 4%, provisioning 20 throwaway Cloud Run services either way. - findOrphans reports drill hubs in their own bucket. They never have a tenant record, so the audit called every running drill an orphan — a nightly false positive, which is how a check stops being read. Signed-off-by: xNet Test <test@xnet.dev>
… banner - deploy-cloud.yml gains a production job: manual dispatch only (no push trigger), its own CLOUD_PROD_DEPLOY_ENABLED variable and reviewer-protected environment, and DISTINCT Stripe live secret names — sharing a name with staging is how test keys reach production or live keys charge a real card during a test run. It refuses to deploy without resend-api-key present, since production emails a tenant before degrading or deleting their data. - The pricing FAQ now says what the backups actually are: continuous async replication, seconds not zero, with the last second at risk on abrupt loss — plus what a failed payment does, step by step with the real windows. - privacy.astro documents lifecycle email and says plainly that you cannot unsubscribe from it, because we will not change your data on a schedule you were never told about. terms.astro states the grace/read-only/pause/delete sequence. - hub-error-banner.ts maps write rejections to real copy. 'Sync error' reads as 'something is broken and your data is at risk' when the truth is 'a card expired'. Unknown codes return null rather than inventing reassurance. Signed-off-by: xNet Test <test@xnet.dev>
All Phase 1 CODE is done. Remaining Phase 1 items need a credential, a DNS record or a provider dashboard and are tagged [operator] / [needs live env]; Phase 2 and 3 (the UI overhaul) are deliberately sequenced after the first paying users, per the exploration's own recommendation. Also corrects two things the implementation proved wrong about the doc: - the example reconcile job used the inline spread that silently breaks billingState; it now shows reconcileInputFor, as shipped. - stageExportBundle cannot produce a .xnetpack from the control plane, and the checklist now says why rather than claiming it does. Signed-off-by: xNet Test <test@xnet.dev>
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (32)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🖼️ UI changes in this PRNo UI changes detected in this PR. |
|
Preview removed for PR #673. |
Implements Phase 1 of exploration 0418 — the code half of what stands between the staging control plane and charging a real person $5/month.
The problem this fixes
The non-payment state machine was written, exhaustively tested, and inert.
reconcileBilling— the timer half — had zero non-test callers. In production a failed payment opened grace, set a deadline, and then nothing ever happened again. The lifecycle we documented did not exist.Two things made it unfixable by just adding a caller:
read_onlyhad no lever. The hub had no read-only mode, so the gentlest, most user-respecting rung was the one step we could not take.What landed
writesEnabledonPlanEntitlements507 billing_read_only) andNodeRelayServicereconcile/billing-driver.tsbilling/notify.tsstageExportBundleBackupHealthBoolean(env.R2_BUCKET)deploy-cloud.ymlDecisions worth reviewing
Read-only is gated in two places. The change log arrives over the sync socket, so an HTTP-only guard would have stopped nothing — it would have looked like a feature and been theatre.
Absent
writesEnabledmeans enabled.verifyEntitlementsnormalizes a missing field totrue. Treating it asfalsewould brick every hub in the fleet the moment this ships. Only an explicitfalseblocks writes. A self-hosted hub has noHUB_PLANand is structurally unreachable by this — asserted by test, not by inspection.A failed notice means the step is not applied.
pending_deletionnotifies before recording, because a final notice nobody received is worse than a clock that never started. A tenant with no resolvable email address makes the notifier throw rather than skip: we do not degrade an account we cannot warn.deleteships off behindXNET_CLOUD_DUNNING_DELETE_ENABLED, and the control plane refuses to boot with it armed and no mail transport. The funnel runs topending_deletionand stops, which is safe to sit in indefinitely.stageExportBundlewas built differently than the exploration planned. It cannot produce a.xnetpack: the hub's export needs a UCAN signed by the user's data DID and we hold no such key. Anything that could build a readable bundle would mean we could read their data. It records a dated retention hold on the encrypted replica instead, and the final-notice email tells the user to export from a device they already have. The doc has been corrected to say so.reconcileInputForexists because of a real trap.DunningStatenames the fieldstate;BillingReconcileInputnames itbillingState. The obvious{ ...tenant.billing, nowMs }spread compiles, leavesbillingStateundefined, and makes every healthy tenant look eligible forreactivateon every tick. A test caught it; routing every caller through one function means nobody finds it twice.Also
The orphan audit called every running restore drill an orphan — a nightly false positive, which is how a check stops being read. Drill hubs now get their own bucket.
drillSampleSizescales with the fleet instead of provisioning 20 throwaway Cloud Run services a night regardless of size.The pricing FAQ now says what the backups actually are: continuous asynchronous replication, seconds not zero, with roughly the last second at risk on abrupt machine loss. That is a weaker claim than before and a true one.
Verification
pnpm typecheck— cleanpnpm lint— 0 errorspnpm build— 57/57pnpm test— 11,887 passingTwo unrelated failures in the full run (
packages/history/src/scrub-perf.test.ts,apps/web/src/boot/demo-seed.test.ts). I confirmed the demo-seed one fails identically on a cleanorigin/mainworktree; the perf test passes in isolation. Neither file nor its dependencies are in this diff.Not in this PR
Everything left in 0418 needs a credential, a DNS record, or a provider dashboard — tagged [operator] in the checklist. The Phase 2/3 UI overhaul is deliberately sequenced after the first paying users, per the exploration's own recommendation: the first five users are people you talk to directly, and their feedback beats weeks of redesign against zero usage data.
🤖 Generated with Claude Code