[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. 🫖 Status page with uptime monitoring & API monitoring as code 🫖 openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor

fix(billing): audit the Stripe downgrade cascade via service verbs (#2402) master

* fix(billing): audit the Stripe downgrade cascade via service verbs The customerSubscriptionDeleted webhook trimmed monitors, pages, notifications, members and invitations with inline DB writes, bypassing the audit log — the 2026-06-03 member-removal regression left no trail. Route the whole cascade through existing entity verbs so every mutation is audited in one transaction (fail-closed): - add `updateWorkspacePlan` verb (workspace.update + reason metadata) for the plan flip and limit reset; allow optional metadata on the workspace.update audit action. - extract `removeMemberInWorkspace` from `deleteMember` so the cascade reuses the member.delete audit body without the user-actor guard. - add `downgradeWorkspaceToFree` orchestrating the trim; the webhook now builds a system-actor ServiceContext and calls it, keeping Vercel domain cleanup best-effort outside the transaction. * refactor(billing): add getWorkspaceByStripeId verb; clarify member helper Address review feedback: - extract a `getWorkspaceByStripeId` read verb and route all three Stripe webhook handlers through it instead of inline `db.select().from(workspace)` + `selectWorkspaceSchema.parse`. Returns null for an unknown customer so the webhook keeps mapping that to its own BAD_REQUEST. - clarify `removeMemberInWorkspace` docs: it is the extracted shared delete+audit body that `deleteMember` delegates to (with its owner/self guards), not a duplicate. * fix(billing): audit the paid-tier plan-sync webhook writes Route the customerSubscriptionUpdated and sessionCompleted workspace writes through updateWorkspacePlan so a paid->paid downgrade (and the upgrade paths) emit a workspace.update audit row like the cancellation path already does. Both run as a system actor (stripe-subscription-updated / stripe-session-completed). - customerSubscriptionUpdated passes no `reason`, so the audit no-op-skip drops rows on trivial subscription.updated events where nothing tracked changed; the actor id still identifies the source on rows that land. - sessionCompleted stamps reason/from/to metadata (checkout is always a deliberate plan set). No trimming is added on plan reductions between paid tiers — that asymmetry is intentional (premium booleans are identical across paid tiers; count limits are write-time gated, freezing an over-limit workspace in place rather than deleting a paying customer's resources). Adds direct updateWorkspacePlan tests (column write, reason metadata, read-only rejection). * chore(services): drop unused import, tidy import ordering Review polish: remove the unused `and` import from the downgrade test and order the workspace schema/list imports (barrel before deep path). No behavior change. * ci: apply automated fixes * fix(billing): reset allowIndex on downgraded survivor page cubic review: the no-index feature (hiding a page from search engines) is paid-only, but the downgrade cascade left the surviving page's allowIndex untouched — a page with allowIndex=false kept the paid no-index restriction on free. Pass allowIndex: true through the password-protection strip so the survivor becomes indexable again, alongside the custom-domain / password / access-type reset. Covers it in the page-strip test (seed allowIndex=false, assert true). * test(workspace): compare limits by parsed content, not raw JSON string updateWorkspacePlan persists `limitsSchema`-parsed JSON, whose key order follows the schema declaration — not the config-object order returned by `getLimits`. The `.toBe(JSON.stringify(getLimits(...)))` assertions in the downgrade and updateWorkspacePlan tests compared bytes and failed on the reordering (identical content). Parse both sides and `toEqual` instead. * test(status-page): isolate free-plan update tests from parallel cleanup The "new fields limit enforcement" block shared one ws2PageId across three update tests. The services suites clear workspace-2 pages on committed rows in beforeAll (cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID)); running in parallel, that delete could remove the shared page mid-block, so the AUTHENTICATED update hit a deleted page and returned 404 instead of 403. Give each update test its own create/use/delete page with a unique slug — the same self-contained pattern the IP-restriction enforcement block already uses — shrinking the race window from spanning three tests to a single await. * test(services): isolate free-plan quota tests from the shared workspace The free-plan negative-path tests in page.test.ts and notification.test.ts bound to the shared seeded free workspace (#2) and assumed exclusive control of its status-pages / notification-channels quota (limit 1). Under parallel execution the apps/server RPC suites commit pages into workspace 2, so those quota checks intermittently tripped LimitExceededError on the wrong assertion (e.g. `updatePageCustomTheme rejects when plan lacks custom-theme` at page create, not at the theme gate). Give each suite its own freshly-inserted free-plan workspace (the repo's prevailing "isolate every test with its own workspace" convention). Because every test writes inside a rolled-back transaction, the committed count stays at zero, so the quota assertions are deterministic regardless of what other suites do to workspace 2. Also drop the now-unneeded cleanQuotaGatedTables setup and the unnecessary page create in the custom-theme test (its gate fires before the page lookup). --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>


+1132 -230
14 changed files