diff --git a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts index fbdf228c..0abbf38d 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts @@ -3654,8 +3654,6 @@ describe("StatusPageService.UpdateStatusPage — new fields", () => { }); describe("StatusPageService — new fields limit enforcement (workspace 2 / free plan)", () => { - let ws2PageId: number; - test("returns 403 when creating with custom_domain on free plan", async () => { const res = await connectRequest( "CreateStatusPage", @@ -3700,37 +3698,55 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free expect(res.status).toBe(403); }); + // Each update test creates and deletes its OWN page rather than sharing a + // `ws2PageId` across the block: the services suites clear workspace-2 pages + // (`cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID)`) on committed rows in + // parallel, so a page persisted across several tests can vanish mid-block + // and turn the expected 403 into a 404. Mirrors the IP-restriction block. test("returns 403 when updating with custom_domain on free plan", async () => { const ws2Page = await db .insert(page) .values({ workspaceId: 2, - title: `${TEST_PREFIX}-limit-update-ws2`, - slug: `${TEST_PREFIX}-limit-update-ws2-slug`, + title: `${TEST_PREFIX}-limit-update-ws2-cd`, + slug: `${TEST_PREFIX}-limit-update-ws2-cd-slug`, description: "Free plan page", customDomain: "", }) .returning() .get(); - ws2PageId = ws2Page.id; const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), customDomain: "status.freeplan.com", }, { "x-openstatus-key": "2" }, ); expect(res.status).toBe(403); + + await db.delete(page).where(eq(page.id, ws2Page.id)); }); test("returns 403 when updating with PASSWORD_PROTECTED on free plan", async () => { + const ws2Page = await db + .insert(page) + .values({ + workspaceId: 2, + title: `${TEST_PREFIX}-limit-update-ws2-pw`, + slug: `${TEST_PREFIX}-limit-update-ws2-pw-slug`, + description: "Free plan page", + customDomain: "", + }) + .returning() + .get(); + const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), accessType: "PAGE_ACCESS_TYPE_PASSWORD_PROTECTED", password: "secret", }, @@ -3738,13 +3754,27 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free ); expect(res.status).toBe(403); + + await db.delete(page).where(eq(page.id, ws2Page.id)); }); test("returns 403 when updating with AUTHENTICATED on free plan", async () => { + const ws2Page = await db + .insert(page) + .values({ + workspaceId: 2, + title: `${TEST_PREFIX}-limit-update-ws2-auth`, + slug: `${TEST_PREFIX}-limit-update-ws2-auth-slug`, + description: "Free plan page", + customDomain: "", + }) + .returning() + .get(); + const res = await connectRequest( "UpdateStatusPage", { - id: String(ws2PageId), + id: String(ws2Page.id), accessType: "PAGE_ACCESS_TYPE_AUTHENTICATED", authEmailDomains: ["example.com"], }, @@ -3753,7 +3783,7 @@ describe("StatusPageService — new fields limit enforcement (workspace 2 / free expect(res.status).toBe(403); - await db.delete(page).where(eq(page.id, ws2PageId)); + await db.delete(page).where(eq(page.id, ws2Page.id)); }); }); diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 8eac89de..c41d93b8 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -1,16 +1,12 @@ import { Events, setupAnalytics } from "@openstatus/analytics"; -import { and, asc, eq, isNull, ne } from "@openstatus/db"; +import { eq } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; +import type { ServiceContext } from "@openstatus/services"; import { - invitation, - monitor, - notification, - page, - selectWorkspaceSchema, - user, - usersToWorkspaces, - workspace, -} from "@openstatus/db/src/schema"; -import { getLimits } from "@openstatus/db/src/schema/plan/utils"; + downgradeWorkspaceToFree, + getWorkspaceByStripeId, + updateWorkspacePlan, +} from "@openstatus/services/workspace"; import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; @@ -62,19 +58,17 @@ export const webhookRouter = createTRPCRouter({ ? subscription.customer : subscription.customer.id; - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.stripeId, customerId)) - .get(); - if (!result) { + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } - const ws = selectWorkspaceSchema.parse(result); const oldPlan = ws.plan; const built = buildFromSubscriptionOrThrow(subscription); @@ -85,17 +79,24 @@ export const webhookRouter = createTRPCRouter({ return; } - await opts.ctx.db - .update(workspace) - .set({ + // No `reason` metadata: `customer.subscription.updated` fires on trivial + // changes too, so let the audit no-op-skip drop rows where nothing + // tracked changed. The `stripe-subscription-updated` actor id still + // identifies the source on the rows that do land. + await updateWorkspacePlan({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-subscription-updated" }, + db: opts.ctx.db, + }, + input: { plan: built.plan, subscriptionId: subscription.id, endsAt: new Date(subscription.current_period_end * 1000), paidUntil: new Date(subscription.current_period_end * 1000), - limits: JSON.stringify(built.limits), - }) - .where(eq(workspace.id, result.id)) - .run(); + limits: built.limits, + }, + }); const allActive = await stripe.subscriptions.list({ customer: customerId, @@ -134,7 +135,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, - workspaceId: String(result.id), + workspaceId: String(ws.id), plan: newPlan, }); await analytics.track(event); @@ -157,12 +158,11 @@ export const webhookRouter = createTRPCRouter({ ? subscription.customer : subscription.customer.id; - const result = await opts.ctx.db - .select() - .from(workspace) - .where(eq(workspace.stripeId, customerId)) - .get(); - if (!result) { + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", @@ -178,17 +178,21 @@ export const webhookRouter = createTRPCRouter({ }); } - await opts.ctx.db - .update(workspace) - .set({ + await updateWorkspacePlan({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-session-completed" }, + db: opts.ctx.db, + }, + input: { plan: built.plan, subscriptionId: subscription.id, endsAt: new Date(subscription.current_period_end * 1000), paidUntil: new Date(subscription.current_period_end * 1000), - limits: JSON.stringify(built.limits), - }) - .where(eq(workspace.id, result.id)) - .run(); + limits: built.limits, + reason: "checkout_session_completed", + }, + }); const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { @@ -202,7 +206,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, - workspaceId: String(result.id), + workspaceId: String(ws.id), plan: built.plan, }); await analytics.track(Events.UpgradeWorkspace); @@ -224,124 +228,30 @@ export const webhookRouter = createTRPCRouter({ return; } - const { workspaces, customDomains } = await opts.ctx.db.transaction( - async (tx) => { - const _workspace = await tx - .update(workspace) - .set({ - subscriptionId: null, - plan: "free", - paidUntil: null, - endsAt: null, - limits: JSON.stringify(getLimits("free")), - }) - .where(eq(workspace.stripeId, customerId)) - .returning(); - - if (!_workspace.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Workspace not found", - }); - } - - const workspaceId = _workspace[0].id; - - const activeMonitors = await tx - .select({ id: monitor.id }) - .from(monitor) - .where( - and( - eq(monitor.workspaceId, workspaceId), - eq(monitor.active, true), - isNull(monitor.deletedAt), - ), - ) - .orderBy(asc(monitor.createdAt)); - - for (const m of activeMonitors.slice(1)) { - await tx - .update(monitor) - .set({ active: false, updatedAt: new Date() }) - .where(eq(monitor.id, m.id)) - .run(); - } - - const statusPages = await tx - .select({ id: page.id, customDomain: page.customDomain }) - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .orderBy(asc(page.createdAt)); - - const customDomains = [ - ...new Set( - statusPages - .map((p) => p.customDomain) - .filter((domain) => domain !== ""), - ), - ]; - - for (const p of statusPages.slice(1)) { - await tx.delete(page).where(eq(page.id, p.id)).run(); - } - - if (statusPages.length > 0) { - await tx - .update(page) - .set({ - customDomain: "", - password: null, - accessType: "public", - authEmailDomains: null, - updatedAt: new Date(), - }) - .where(eq(page.id, statusPages[0].id)) - .run(); - } - - const notifications = await tx - .select({ id: notification.id, provider: notification.provider }) - .from(notification) - .where(eq(notification.workspaceId, workspaceId)) - .orderBy(asc(notification.createdAt)); - - const keepNotification = - notifications.find((n) => n.provider === "email") ?? notifications[0]; - - for (const n of notifications.filter( - (n) => n.id !== keepNotification?.id, - )) { - await tx.delete(notification).where(eq(notification.id, n.id)).run(); - } - - // Remove all non-owner members from the workspace - await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.workspaceId, workspaceId), - ne(usersToWorkspaces.role, "owner"), - ), - ) - .run(); - - // Remove all pending invitations for the workspace - await tx - .delete(invitation) - .where(eq(invitation.workspaceId, workspaceId)) - .run(); - - return { workspaces: _workspace, customDomains }; - }, - ); + const ws = await getWorkspaceByStripeId({ + input: { stripeId: customerId }, + db: opts.ctx.db, + }); - if (!workspaces[0]) { + if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } + // System actor — no user is attributable to an involuntary Stripe + // cancellation. The service verb runs the whole trim in one audited + // transaction; a failed audit insert rolls the downgrade back and the + // webhook returns non-2xx so Stripe retries. + const ctx: ServiceContext = { + workspace: ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: opts.ctx.db, + }; + + const { customDomains } = await downgradeWorkspaceToFree({ ctx }); + // Free plan has no custom-domain feature — release each domain on Vercel // unless another workspace's page still holds it. Best-effort after // commit: a Vercel error must not fail the webhook into Stripe retries. @@ -356,7 +266,6 @@ export const webhookRouter = createTRPCRouter({ } } - const workspaceId = workspaces[0].id; const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { @@ -370,7 +279,7 @@ export const webhookRouter = createTRPCRouter({ const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: customer.email || undefined, - workspaceId: String(workspaceId), + workspaceId: String(ws.id), plan: "free", }); await analytics.track(Events.DowngradeWorkspace); diff --git a/packages/db/src/schema/audit_logs/validation.ts b/packages/db/src/schema/audit_logs/validation.ts index 8220a2f0..ad2076df 100644 --- a/packages/db/src/schema/audit_logs/validation.ts +++ b/packages/db/src/schema/audit_logs/validation.ts @@ -99,7 +99,7 @@ const notificationActions = [ const userActions = [action("user.delete", "user", intId)] as const; const workspaceActions = [ - action("workspace.update", "workspace", intId), + action("workspace.update", "workspace", intId, { optionalMetadata: true }), ] as const; const maintenanceActions = [ diff --git a/packages/services/src/member/delete.ts b/packages/services/src/member/delete.ts index 7329c325..5e467c65 100644 --- a/packages/services/src/member/delete.ts +++ b/packages/services/src/member/delete.ts @@ -1,24 +1,12 @@ import { and, eq } from "@openstatus/db"; import { usersToWorkspaces } from "@openstatus/db/src/schema"; -import { z } from "zod"; -import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { NotFoundError, PreconditionFailedError } from "../errors"; +import { removeMemberInWorkspace } from "./internal"; import { DeleteMemberInput } from "./schemas"; -// Composite-PK rows: drizzle's createSelectSchema would flatten the join, -// but the membership row has no auto-generated columns we'd want to drop -// from a snapshot. Inline parser keeps the audit `before` shape stable -// even if a column is added to `users_to_workspaces` later. -const memberRowSnapshot = z.object({ - userId: z.number(), - workspaceId: z.number(), - role: z.string(), - createdAt: z.coerce.date().nullable(), -}); - /** * Delete a member's workspace association. Idempotent on the target row — * if the target user has no membership in the caller's workspace (wrong id, @@ -77,23 +65,6 @@ export async function deleteMember(args: { ); } - const [removed] = await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.workspaceId, ctx.workspace.id), - eq(usersToWorkspaces.userId, input.userId), - ), - ) - .returning(); - - if (!removed) return; - - await emitAudit(tx, ctx, { - action: "member.delete", - entityType: "member", - entityId: input.userId, - before: memberRowSnapshot.parse(removed), - }); + await removeMemberInWorkspace({ tx, ctx, userId: input.userId }); }); } diff --git a/packages/services/src/member/internal.ts b/packages/services/src/member/internal.ts new file mode 100644 index 00000000..1d50f2d6 --- /dev/null +++ b/packages/services/src/member/internal.ts @@ -0,0 +1,55 @@ +import { and, eq } from "@openstatus/db"; +import { usersToWorkspaces } from "@openstatus/db/src/schema"; +import { z } from "zod"; + +import { emitAudit } from "../audit"; +import { type DB, type ServiceContext } from "../context"; + +// Composite-PK rows: drizzle's createSelectSchema would flatten the join, +// but the membership row has no auto-generated columns we'd want to drop +// from a snapshot. Inline parser keeps the audit `before` shape stable +// even if a column is added to `users_to_workspaces` later. +export const memberRowSnapshot = z.object({ + userId: z.number(), + workspaceId: z.number(), + role: z.string(), + createdAt: z.coerce.date().nullable(), +}); + +/** + * Shared delete+audit body for a single membership row — the sole place + * the `member.delete` write lives. `deleteMember` wraps this with its + * owner-actor / self-removal guards; the Stripe downgrade cascade calls it + * directly as a `system` actor. This is an extraction, not a duplicate: + * the guard-bearing entry point (`deleteMember`) delegates here so the two + * callers can't drift on the workspace-scoping or the audit snapshot. + * + * No authorization guard of its own — callers own that. Idempotent on the + * target row: a missing membership deletes nothing and emits no audit row. + */ +export async function removeMemberInWorkspace(args: { + tx: DB; + ctx: ServiceContext; + userId: number; +}): Promise { + const { tx, ctx, userId } = args; + + const [removed] = await tx + .delete(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, ctx.workspace.id), + eq(usersToWorkspaces.userId, userId), + ), + ) + .returning(); + + if (!removed) return; + + await emitAudit(tx, ctx, { + action: "member.delete", + entityType: "member", + entityId: userId, + before: memberRowSnapshot.parse(removed), + }); +} diff --git a/packages/services/src/notification/__tests__/notification.test.ts b/packages/services/src/notification/__tests__/notification.test.ts index 5082f971..e0d1fea7 100644 --- a/packages/services/src/notification/__tests__/notification.test.ts +++ b/packages/services/src/notification/__tests__/notification.test.ts @@ -3,16 +3,15 @@ import { monitor, notification, notificationsToMonitors, + selectWorkspaceSchema, + workspace, } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; import { - SEEDED_WORKSPACE_FREE_ID, - SEEDED_WORKSPACE_TEAM_ID, -} from "../../../test/fixtures"; -import { - cleanQuotaGatedTables, expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, @@ -37,17 +36,34 @@ let teamCtx: ServiceContext; let freeCtx: ServiceContext; let teamMonitorId: number; +// Dedicated, freshly-inserted free-plan workspace for the quota-sensitive +// negative-path tests (`notification-channels: 1`). The shared seeded free +// workspace (#2) is written concurrently by the apps/server RPC suites under +// parallel test execution, which intermittently exhausts the quota and fails +// the wrong assertion. An isolated workspace removes that cross-suite race; +// every test writes inside a rolled-back transaction, so its committed +// notification count stays at zero for the whole suite. +const FREE_WS_SLUG = `${TEST_PREFIX}-free-ws`; + beforeAll(async () => { const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); - const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); teamCtx = makeUserCtx(team, { userId: 1 }); - freeCtx = makeUserCtx(free, { userId: 2 }); - // Clear quota-gated rows on the free workspace so - // `notification-channels: 1` (free plan) can actually be exercised - // by negative-path tests — any leftover row from prior runs tripped - // `LimitExceededError` before the intended assertion fired. - await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); + const freeRow = await db + .insert(workspace) + .values({ + slug: FREE_WS_SLUG, + name: `${TEST_PREFIX}-free`, + plan: "free", + limits: JSON.stringify(getLimits("free")), + }) + .returning() + .get(); + freeCtx = makeUserCtx(selectWorkspaceSchema.parse(freeRow), { userId: 2 }); const monitorRow = await db .insert(monitor) @@ -70,6 +86,10 @@ afterAll(async () => { .delete(monitor) .where(eq(monitor.id, teamMonitorId)) .catch(() => undefined); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); }); describe("createNotification", () => { @@ -326,7 +346,7 @@ describe("updateNotification", () => { const [inserted] = await tx .insert(notification) .values({ - workspaceId: SEEDED_WORKSPACE_FREE_ID, + workspaceId: freeCtx.workspace.id, name: `${TEST_PREFIX}-downgrade-gate`, provider: "pagerduty", data: JSON.stringify({ diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts index 4cfcb98f..fa7af6ca 100644 --- a/packages/services/src/page/__tests__/page.test.ts +++ b/packages/services/src/page/__tests__/page.test.ts @@ -3,16 +3,15 @@ import { monitor, page as pageTable, pageComponent, + selectWorkspaceSchema, + workspace, } from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; +import { SEEDED_WORKSPACE_TEAM_ID } from "../../../test/fixtures"; import { - SEEDED_WORKSPACE_FREE_ID, - SEEDED_WORKSPACE_TEAM_ID, -} from "../../../test/fixtures"; -import { - cleanQuotaGatedTables, expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, @@ -41,17 +40,35 @@ let teamCtx: ServiceContext; let freeCtx: ServiceContext; let teamMonitorId: number; +// Dedicated, freshly-inserted free-plan workspace for the quota-sensitive +// negative-path tests (status-pages limit = 1). They assume exclusive control +// of the workspace's page count, but the shared seeded free workspace (#2) is +// written concurrently by the apps/server RPC suites under parallel test +// execution — which intermittently exhausts the quota and fails the wrong +// assertion. An isolated workspace removes that cross-suite race; because every +// test writes inside a rolled-back transaction, its committed page count stays +// at zero for the whole suite. +const FREE_WS_SLUG = `${TEST_PREFIX}-free-ws`; + beforeAll(async () => { const team = await loadSeededWorkspace(SEEDED_WORKSPACE_TEAM_ID); - const free = await loadSeededWorkspace(SEEDED_WORKSPACE_FREE_ID); teamCtx = makeUserCtx(team, { userId: 1 }); - freeCtx = makeUserCtx(free, { userId: 2 }); - // Clear leftover quota-gated rows on the free workspace so - // negative-path tests hit their intended assertion (e.g. - // `assertStatusPageQuota` on free = 1 page) regardless of what - // prior runs left behind. - await cleanQuotaGatedTables(SEEDED_WORKSPACE_FREE_ID); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); + const freeRow = await db + .insert(workspace) + .values({ + slug: FREE_WS_SLUG, + name: `${TEST_PREFIX}-free`, + plan: "free", + limits: JSON.stringify(getLimits("free")), + }) + .returning() + .get(); + freeCtx = makeUserCtx(selectWorkspaceSchema.parse(freeRow), { userId: 2 }); const teamMonitor = await db .insert(monitor) @@ -74,6 +91,10 @@ afterAll(async () => { .delete(monitor) .where(eq(monitor.id, teamMonitorId)) .catch(() => undefined); + await db + .delete(workspace) + .where(eq(workspace.slug, FREE_WS_SLUG)) + .catch(() => undefined); }); let slugCounter = 0; @@ -160,7 +181,7 @@ describe("createPage (full form)", () => { slug, description: "", customDomain: "", - workspaceId: SEEDED_WORKSPACE_FREE_ID, + workspaceId: freeCtx.workspace.id, monitors: [{ monitorId: teamMonitorId }], }, }), @@ -352,14 +373,15 @@ describe("updatePageCustomTheme", () => { test("rejects when plan lacks custom-theme", async () => { await withTestTransaction(async (tx) => { const ctx = { ...freeCtx, db: tx }; - const p = await newPage({ - ctx, - input: { title: "Free Theme", slug: uniqueSlug("free-theme") }, - }); + // No page needed: the `custom-theme` limit check fires before the + // page lookup (mirrors the read-only-actor case below). Creating a + // page here would depend on the shared free workspace's status-pages + // quota, which a parallel suite can exhaust → flaky LimitExceededError + // on the wrong assertion. await expect( updatePageCustomTheme({ ctx, - input: { id: p.id, customTheme: { light: { "--primary": "red" } } }, + input: { id: 1, customTheme: { light: { "--primary": "red" } } }, }), ).rejects.toBeInstanceOf(LimitExceededError); }); diff --git a/packages/services/src/workspace/__tests__/downgrade.test.ts b/packages/services/src/workspace/__tests__/downgrade.test.ts new file mode 100644 index 00000000..ebc361d2 --- /dev/null +++ b/packages/services/src/workspace/__tests__/downgrade.test.ts @@ -0,0 +1,430 @@ +import { eq } from "@openstatus/db"; +import { + invitation, + monitor, + notification, + page, + selectWorkspaceSchema, + user, + usersToWorkspaces, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + expectAuditRow, + makeApiKeyCtx, + readAuditLog, + withTestTransaction, +} from "../../../test/helpers"; +import type { DrizzleTx, ServiceContext } from "../../context"; +import { ForbiddenError } from "../../errors"; +import { downgradeWorkspaceToFree } from "../index.ts"; + +const OLDEST = new Date("2020-01-01T00:00:00Z"); +const NEWER = new Date("2021-01-01T00:00:00Z"); + +/** + * Insert a self-contained `team` workspace with everything the free tier + * can't hold: two active monitors, two pages (the survivor carrying paid + * access features), two notifications, an owner + two members, and a + * pending + accepted invitation. Everything is scoped to the returned + * workspace so assertions don't collide with seed data. Rolled back by the + * enclosing `withTestTransaction`. + */ +async function seedTeamWorkspace(tx: DrizzleTx) { + const wsRow = await tx + .insert(workspace) + .values({ + slug: "svc-downgrade-test", + name: "Downgrade Test", + plan: "team", + stripeId: "cus_svc_downgrade_test", + subscriptionId: "sub_svc_downgrade_test", + limits: JSON.stringify(getLimits("team")), + }) + .returning() + .get(); + const ws = selectWorkspaceSchema.parse(wsRow); + + const ownerUserId = 900_001; + const memberAId = 900_002; + const memberBId = 900_003; + await tx.insert(user).values([ + { id: ownerUserId, tenantId: "svc-downgrade-owner" }, + { id: memberAId, tenantId: "svc-downgrade-member-a" }, + { id: memberBId, tenantId: "svc-downgrade-member-b" }, + ]); + await tx.insert(usersToWorkspaces).values([ + { workspaceId: ws.id, userId: ownerUserId, role: "owner" }, + { workspaceId: ws.id, userId: memberAId, role: "member" }, + { workspaceId: ws.id, userId: memberBId, role: "admin" }, + ]); + + const oldestMonitor = await tx + .insert(monitor) + .values({ + workspaceId: ws.id, + url: "https://oldest.example.com", + active: true, + createdAt: OLDEST, + }) + .returning() + .get(); + const newerMonitor = await tx + .insert(monitor) + .values({ + workspaceId: ws.id, + url: "https://newer.example.com", + active: true, + createdAt: NEWER, + }) + .returning() + .get(); + + const keptPage = await tx + .insert(page) + .values({ + workspaceId: ws.id, + title: "Kept Page", + description: "", + slug: "svc-downgrade-kept", + customDomain: "status.acme.test", + password: "hunter2", + accessType: "password", + allowIndex: false, + createdAt: OLDEST, + }) + .returning() + .get(); + const deletedPage = await tx + .insert(page) + .values({ + workspaceId: ws.id, + title: "Deleted Page", + description: "", + slug: "svc-downgrade-deleted", + customDomain: "", + createdAt: NEWER, + }) + .returning() + .get(); + + const emailNotification = await tx + .insert(notification) + .values({ + workspaceId: ws.id, + name: "keep-email", + provider: "email", + data: "{}", + createdAt: NEWER, + }) + .returning() + .get(); + const discordNotification = await tx + .insert(notification) + .values({ + workspaceId: ws.id, + name: "drop-discord", + provider: "discord", + data: "{}", + createdAt: OLDEST, + }) + .returning() + .get(); + + const pendingInvitation = await tx + .insert(invitation) + .values({ + workspaceId: ws.id, + email: "pending@example.test", + token: "svc-downgrade-pending", + expiresAt: NEWER, + }) + .returning() + .get(); + const acceptedInvitation = await tx + .insert(invitation) + .values({ + workspaceId: ws.id, + email: "accepted@example.test", + token: "svc-downgrade-accepted", + expiresAt: NEWER, + acceptedAt: OLDEST, + }) + .returning() + .get(); + + return { + ws, + ownerUserId, + memberAId, + memberBId, + oldestMonitor, + newerMonitor, + keptPage, + deletedPage, + emailNotification, + discordNotification, + pendingInvitation, + acceptedInvitation, + }; +} + +describe("downgradeWorkspaceToFree", () => { + test("flips the plan to free and resets billing columns", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, s.ws.id)) + .get(); + expect(after?.plan).toBe("free"); + expect(after?.subscriptionId).toBeNull(); + expect(after?.paidUntil).toBeNull(); + expect(after?.endsAt).toBeNull(); + // Compare parsed content, not the raw string — the verb persists + // `limitsSchema`-canonicalised JSON (key order differs from the + // config object returned by `getLimits`). + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("free")); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "workspace.update", + entityType: "workspace", + entityId: s.ws.id, + actorType: "system", + db: tx, + }); + + const [wsAudit] = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "workspace", + entityId: s.ws.id, + db: tx, + }); + expect(wsAudit?.metadata).toMatchObject({ + reason: "subscription_deleted", + from: "team", + to: "free", + }); + }); + }); + + test("deactivates all but the oldest active monitor", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const oldest = await tx + .select() + .from(monitor) + .where(eq(monitor.id, s.oldestMonitor.id)) + .get(); + const newer = await tx + .select() + .from(monitor) + .where(eq(monitor.id, s.newerMonitor.id)) + .get(); + expect(oldest?.active).toBe(true); + expect(newer?.active).toBe(false); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "monitor.update", + entityType: "monitor", + entityId: s.newerMonitor.id, + db: tx, + }); + }); + }); + + test("deletes all but the oldest page and strips the survivor's paid features", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + const { customDomains } = await downgradeWorkspaceToFree({ ctx }); + expect(customDomains).toContain("status.acme.test"); + + const deleted = await tx + .select() + .from(page) + .where(eq(page.id, s.deletedPage.id)) + .get(); + expect(deleted).toBeUndefined(); + + const kept = await tx + .select() + .from(page) + .where(eq(page.id, s.keptPage.id)) + .get(); + expect(kept?.customDomain).toBe(""); + expect(kept?.password).toBeNull(); + expect(kept?.accessType).toBe("public"); + // no-index is paid-only — the survivor must become indexable again. + expect(kept?.allowIndex).toBe(true); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "page.delete", + entityType: "page", + entityId: s.deletedPage.id, + db: tx, + }); + await expectAuditRow({ + workspaceId: s.ws.id, + action: "page.update", + entityType: "page", + entityId: s.keptPage.id, + db: tx, + }); + }); + }); + + test("keeps the email notification, deletes the rest", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const kept = await tx + .select() + .from(notification) + .where(eq(notification.id, s.emailNotification.id)) + .get(); + const dropped = await tx + .select() + .from(notification) + .where(eq(notification.id, s.discordNotification.id)) + .get(); + expect(kept).toBeDefined(); + expect(dropped).toBeUndefined(); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "notification.delete", + entityType: "notification", + entityId: s.discordNotification.id, + db: tx, + }); + }); + }); + + test("removes every non-owner member, one audit row each", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const remaining = await tx + .select() + .from(usersToWorkspaces) + .where(eq(usersToWorkspaces.workspaceId, s.ws.id)) + .all(); + expect(remaining.map((r) => r.userId).sort()).toEqual([s.ownerUserId]); + + for (const userId of [s.memberAId, s.memberBId]) { + await expectAuditRow({ + workspaceId: s.ws.id, + action: "member.delete", + entityType: "member", + entityId: userId, + actorType: "system", + db: tx, + }); + } + }); + }); + + test("deletes pending invitations but keeps accepted ones", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + workspace: s.ws, + actor: { type: "system", job: "stripe-subscription-deleted" }, + db: tx, + }; + + await downgradeWorkspaceToFree({ ctx }); + + const pending = await tx + .select() + .from(invitation) + .where(eq(invitation.id, s.pendingInvitation.id)) + .get(); + const accepted = await tx + .select() + .from(invitation) + .where(eq(invitation.id, s.acceptedInvitation.id)) + .get(); + expect(pending).toBeUndefined(); + expect(accepted).toBeDefined(); + + await expectAuditRow({ + workspaceId: s.ws.id, + action: "invitation.delete", + entityType: "invitation", + entityId: s.pendingInvitation.id, + db: tx, + }); + + const acceptedAudit = await readAuditLog({ + workspaceId: s.ws.id, + entityType: "invitation", + entityId: s.acceptedInvitation.id, + db: tx, + }); + expect(acceptedAudit).toHaveLength(0); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const s = await seedTeamWorkspace(tx); + const ctx: ServiceContext = { + ...makeApiKeyCtx(s.ws, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + + await expect(downgradeWorkspaceToFree({ ctx })).rejects.toBeInstanceOf( + ForbiddenError, + ); + }); + }); +}); diff --git a/packages/services/src/workspace/__tests__/workspace.test.ts b/packages/services/src/workspace/__tests__/workspace.test.ts index 576efa1b..66773520 100644 --- a/packages/services/src/workspace/__tests__/workspace.test.ts +++ b/packages/services/src/workspace/__tests__/workspace.test.ts @@ -1,5 +1,10 @@ import { eq } from "@openstatus/db"; -import { statusReport, workspace } from "@openstatus/db/src/schema"; +import { + selectWorkspaceSchema, + statusReport, + workspace, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; import { expect } from "@std/expect"; import { beforeAll, describe, test } from "@std/testing/bdd"; @@ -8,16 +13,20 @@ import { expectAuditRow, loadSeededWorkspace, makeApiKeyCtx, + makeSystemCtx, makeUserCtx, + readAuditLog, withTestTransaction, } from "../../../test/helpers"; -import type { ServiceContext } from "../../context"; +import type { DrizzleTx, ServiceContext } from "../../context"; import { ForbiddenError } from "../../errors"; import { getWorkspace, + getWorkspaceByStripeId, getWorkspaceWithUsage, listWorkspaces, updateWorkspaceName, + updateWorkspacePlan, } from "../index.ts"; let teamCtx: ServiceContext; @@ -155,3 +164,177 @@ describe("updateWorkspaceName", () => { }); }); }); + +describe("getWorkspaceByStripeId", () => { + test("resolves the workspace for a known stripe customer id", async () => { + await withTestTransaction(async (tx) => { + const inserted = await tx + .insert(workspace) + .values({ + slug: "svc-ws-by-stripe", + name: "Stripe Lookup", + plan: "team", + stripeId: "cus_svc_ws_by_stripe", + limits: JSON.stringify(getLimits("team")), + }) + .returning() + .get(); + + const result = await getWorkspaceByStripeId({ + input: { stripeId: "cus_svc_ws_by_stripe" }, + db: tx, + }); + expect(result?.id).toBe(inserted.id); + expect(typeof result?.limits).toBe("object"); + }); + }); + + test("returns null when no workspace maps to the customer", async () => { + await withTestTransaction(async (tx) => { + const result = await getWorkspaceByStripeId({ + input: { stripeId: "cus_does_not_exist" }, + db: tx, + }); + expect(result).toBeNull(); + }); + }); +}); + +async function insertPlanWorkspace( + tx: DrizzleTx, + opts: { plan: "free" | "starter" | "team" | "scale"; slug: string }, +) { + const row = await tx + .insert(workspace) + .values({ + slug: opts.slug, + name: opts.slug, + plan: opts.plan, + stripeId: `cus_${opts.slug}`, + subscriptionId: `sub_${opts.slug}`, + limits: JSON.stringify(getLimits(opts.plan)), + }) + .returning() + .get(); + return selectWorkspaceSchema.parse(row); +} + +describe("updateWorkspacePlan", () => { + test("writes the new plan + limits and audits the change", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "team", + slug: "svc-plan-downgrade", + }); + const ctx = { + ...makeSystemCtx(ws, { job: "stripe-subscription-updated" }), + db: tx, + }; + + await updateWorkspacePlan({ + ctx, + input: { + plan: "starter", + subscriptionId: "sub_new", + paidUntil: new Date("2027-01-01T00:00:00Z"), + endsAt: new Date("2027-01-01T00:00:00Z"), + limits: getLimits("starter"), + }, + }); + + const after = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ws.id)) + .get(); + expect(after?.plan).toBe("starter"); + expect(after?.subscriptionId).toBe("sub_new"); + // Compare parsed content, not the raw string — the verb persists + // `limitsSchema`-canonicalised JSON (key order differs from the + // config object returned by `getLimits`). + expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("starter")); + + await expectAuditRow({ + workspaceId: ws.id, + action: "workspace.update", + entityType: "workspace", + entityId: ws.id, + actorType: "system", + db: tx, + }); + + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.changedFields).toContain("plan"); + // No `reason` passed → no metadata stamped. + expect(audit?.metadata).toBeNull(); + }); + }); + + test("stamps reason / from / to metadata when a reason is given", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "free", + slug: "svc-plan-checkout", + }); + const ctx = { + ...makeSystemCtx(ws, { job: "stripe-session-completed" }), + db: tx, + }; + + await updateWorkspacePlan({ + ctx, + input: { + plan: "team", + subscriptionId: "sub_checkout", + paidUntil: new Date("2027-01-01T00:00:00Z"), + endsAt: new Date("2027-01-01T00:00:00Z"), + limits: getLimits("team"), + reason: "checkout_session_completed", + }, + }); + + const [audit] = await readAuditLog({ + workspaceId: ws.id, + entityType: "workspace", + entityId: ws.id, + db: tx, + }); + expect(audit?.metadata).toMatchObject({ + reason: "checkout_session_completed", + from: "free", + to: "team", + }); + }); + }); + + test("rejects a read-only api key actor", async () => { + await withTestTransaction(async (tx) => { + const ws = await insertPlanWorkspace(tx, { + plan: "team", + slug: "svc-plan-readonly", + }); + const ctx = { + ...makeApiKeyCtx(ws, { keyId: "k-read", userId: 1, scopes: ["read"] }), + db: tx, + }; + + await expect( + updateWorkspacePlan({ + ctx, + input: { + plan: "starter", + subscriptionId: null, + paidUntil: null, + endsAt: null, + limits: getLimits("starter"), + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts new file mode 100644 index 00000000..3c724f05 --- /dev/null +++ b/packages/services/src/workspace/downgrade.ts @@ -0,0 +1,172 @@ +import { and, asc, eq, isNull, ne } from "@openstatus/db"; +import { + invitation, + monitor, + notification, + page, + usersToWorkspaces, +} from "@openstatus/db/src/schema"; +import { getLimits } from "@openstatus/db/src/schema/plan/utils"; + +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { deleteInvitation } from "../invitation"; +import { removeMemberInWorkspace } from "../member/internal"; +import { bulkUpdateMonitors } from "../monitor"; +import { deleteNotification } from "../notification"; +import { + deletePage, + updatePageCustomDomain, + updatePagePasswordProtection, +} from "../page"; +import { updateWorkspacePlan } from "./update"; + +/** + * Drop a workspace to the `free` plan and trim everything the free tier + * can't hold — the cascade triggered by a Stripe subscription deletion. + * Every step routes through an existing entity verb so the trim is fully + * audited (`monitor.update`, `page.delete`, `notification.delete`, + * `member.delete`, `invitation.delete`) alongside the `workspace.update` + * for the plan flip itself. One transaction: a failed audit insert rolls + * back the whole downgrade. + * + * Trim rules (unchanged from the pre-service webhook): + * - keep the oldest active monitor, deactivate the rest; + * - keep the oldest page, hard-delete the rest, and strip the survivor's + * custom domain / password / access restrictions (free has none); + * - keep one notification (email channel preferred), delete the rest; + * - remove every non-owner member; + * - delete every pending invitation. + * + * Returns the set of custom domains that were attached to any page, so the + * caller can release them on Vercel *after* the transaction commits — + * that cleanup is best-effort and must not roll the downgrade back. + */ +export async function downgradeWorkspaceToFree(args: { + ctx: ServiceContext; +}): Promise<{ customDomains: string[] }> { + const { ctx } = args; + requireScope(ctx, "write"); + const workspaceId = ctx.workspace.id; + + return withTransaction(ctx, async (tx) => { + const txCtx: ServiceContext = { ...ctx, db: tx }; + + await updateWorkspacePlan({ + ctx: txCtx, + input: { + plan: "free", + subscriptionId: null, + paidUntil: null, + endsAt: null, + limits: getLimits("free"), + reason: "subscription_deleted", + }, + }); + + const activeMonitors = await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, workspaceId), + eq(monitor.active, true), + isNull(monitor.deletedAt), + ), + ) + .orderBy(asc(monitor.createdAt)); + + const monitorIdsToDeactivate = activeMonitors.slice(1).map((m) => m.id); + if (monitorIdsToDeactivate.length > 0) { + await bulkUpdateMonitors({ + ctx: txCtx, + input: { ids: monitorIdsToDeactivate, active: false }, + }); + } + + const statusPages = await tx + .select({ id: page.id, customDomain: page.customDomain }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .orderBy(asc(page.createdAt)); + + const customDomains = [ + ...new Set( + statusPages + .map((p) => p.customDomain) + .filter((domain): domain is string => !!domain && domain !== ""), + ), + ]; + + for (const p of statusPages.slice(1)) { + await deletePage({ ctx: txCtx, input: { id: p.id } }); + } + + // Strip the surviving page's paid-only access features. Both verbs + // no-op (no audit row) when the field is already at its free value. + // `allowIndex: true` restores the free default — the `no-index` + // feature (hiding a page from search engines) is paid-only, so a + // survivor that had `allowIndex=false` must become indexable again. + const keptPage = statusPages[0]; + if (keptPage) { + await updatePageCustomDomain({ + ctx: txCtx, + input: { id: keptPage.id, customDomain: "" }, + }); + await updatePagePasswordProtection({ + ctx: txCtx, + input: { + id: keptPage.id, + accessType: "public", + password: null, + authEmailDomains: null, + allowIndex: true, + }, + }); + } + + const notifications = await tx + .select({ id: notification.id, provider: notification.provider }) + .from(notification) + .where(eq(notification.workspaceId, workspaceId)) + .orderBy(asc(notification.createdAt)); + + const keepNotification = + notifications.find((n) => n.provider === "email") ?? notifications[0]; + + for (const n of notifications) { + if (n.id === keepNotification?.id) continue; + await deleteNotification({ ctx: txCtx, input: { id: n.id } }); + } + + const nonOwnerMembers = await tx + .select({ userId: usersToWorkspaces.userId }) + .from(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, workspaceId), + ne(usersToWorkspaces.role, "owner"), + ), + ); + + for (const m of nonOwnerMembers) { + await removeMemberInWorkspace({ tx, ctx: txCtx, userId: m.userId }); + } + + const pendingInvitations = await tx + .select({ id: invitation.id }) + .from(invitation) + .where( + and( + eq(invitation.workspaceId, workspaceId), + isNull(invitation.acceptedAt), + ), + ); + + for (const inv of pendingInvitations) { + await deleteInvitation({ ctx: txCtx, input: { id: inv.id } }); + } + + return { customDomains }; + }); +} diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index ea6109cf..4e8e953f 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -1,14 +1,18 @@ export { getWorkspace, + getWorkspaceByStripeId, getWorkspaceWithUsage, listWorkspaces, type WorkspaceUsage, type WorkspaceWithUsage, } from "./list"; -export { updateWorkspaceName } from "./update"; +export { downgradeWorkspaceToFree } from "./downgrade"; +export { updateWorkspaceName, updateWorkspacePlan } from "./update"; export { + GetWorkspaceByStripeIdInput, GetWorkspaceInput, GetWorkspaceWithUsageInput, ListWorkspacesInput, UpdateWorkspaceNameInput, + UpdateWorkspacePlanInput, } from "./schemas"; diff --git a/packages/services/src/workspace/list.ts b/packages/services/src/workspace/list.ts index ba9f4521..6606a720 100644 --- a/packages/services/src/workspace/list.ts +++ b/packages/services/src/workspace/list.ts @@ -6,10 +6,11 @@ import { workspace, } from "@openstatus/db/src/schema"; -import type { ServiceContext } from "../context"; +import type { DB, ServiceContext } from "../context"; import { NotFoundError } from "../errors"; import type { Workspace } from "../types"; import { + GetWorkspaceByStripeIdInput, type GetWorkspaceWithUsageInput, ListWorkspacesInput, } from "./schemas"; @@ -97,6 +98,28 @@ export async function getWorkspaceWithUsage(args: { return { ...selectWorkspaceSchema.parse(result), usage }; } +/** + * Resolve a workspace by its Stripe customer id. Runs before a + * `ctx.workspace` exists (the Stripe webhook only holds the customer id), + * so it takes an optional `db`/tx rather than a `ServiceContext`. Returns + * `null` when no workspace maps to the customer — an expected case (an + * event for a customer we don't own), which the caller maps to its own + * error rather than a thrown `NotFoundError`. + */ +export async function getWorkspaceByStripeId(args: { + input: GetWorkspaceByStripeIdInput; + db?: DB; +}): Promise { + const { stripeId } = GetWorkspaceByStripeIdInput.parse(args.input); + const db = args.db ?? defaultDb; + + const row = await db.query.workspace.findFirst({ + where: eq(workspace.stripeId, stripeId), + }); + + return row ? selectWorkspaceSchema.parse(row) : null; +} + /** * Workspaces the given user belongs to. Called before `ctx.workspace` is * meaningful (list runs across every workspace the user has access to), so diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index 300c4810..33917227 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -1,3 +1,5 @@ +import { workspacePlanSchema } from "@openstatus/db/src/schema"; +import { limitsSchema } from "@openstatus/db/src/schema/plan/schema"; import { z } from "zod"; export const GetWorkspaceInput = z.object({}).strict(); @@ -11,7 +13,32 @@ export type GetWorkspaceWithUsageInput = z.infer< export const ListWorkspacesInput = z.object({ userId: z.number().int() }); export type ListWorkspacesInput = z.infer; +export const GetWorkspaceByStripeIdInput = z.object({ + stripeId: z.string().min(1), +}); +export type GetWorkspaceByStripeIdInput = z.infer< + typeof GetWorkspaceByStripeIdInput +>; + export const UpdateWorkspaceNameInput = z.object({ name: z.string().min(1), }); export type UpdateWorkspaceNameInput = z.infer; + +/** + * Set a workspace's billing plan and the columns that move with it + * (subscription id, paid-until / ends-at dates, feature limits). Driven + * by the Stripe webhook — `limits` is the structured object; the verb + * serialises it to the `text` column. `reason` is stamped into the audit + * row's `metadata` so a plan change from an involuntary cancellation is + * distinguishable from a checkout upgrade. + */ +export const UpdateWorkspacePlanInput = z.object({ + plan: workspacePlanSchema, + subscriptionId: z.string().nullable(), + paidUntil: z.date().nullable(), + endsAt: z.date().nullable(), + limits: limitsSchema, + reason: z.string().optional(), +}); +export type UpdateWorkspacePlanInput = z.infer; diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts index 6c670fd7..1d1be03e 100644 --- a/packages/services/src/workspace/update.ts +++ b/packages/services/src/workspace/update.ts @@ -5,7 +5,7 @@ import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; import { NotFoundError } from "../errors"; -import { UpdateWorkspaceNameInput } from "./schemas"; +import { UpdateWorkspaceNameInput, UpdateWorkspacePlanInput } from "./schemas"; /** * Rename the caller's workspace. No conflict check — workspace names are @@ -46,3 +46,59 @@ export async function updateWorkspaceName(args: { }); }); } + +/** + * Set the workspace's plan and the billing columns that move with it. + * The single audit `workspace.update` row carries the plan flip in + * `changed_fields`; a `reason` (e.g. `"subscription_deleted"`) is stamped + * into `metadata` so an involuntary downgrade reads differently from a + * checkout upgrade. + */ +export async function updateWorkspacePlan(args: { + ctx: ServiceContext; + input: UpdateWorkspacePlanInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdateWorkspacePlanInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await tx + .select() + .from(workspace) + .where(eq(workspace.id, ctx.workspace.id)) + .get(); + if (!existing) throw new NotFoundError("workspace", ctx.workspace.id); + + const updated = await tx + .update(workspace) + .set({ + plan: input.plan, + subscriptionId: input.subscriptionId, + paidUntil: input.paidUntil, + endsAt: input.endsAt, + limits: JSON.stringify(input.limits), + updatedAt: new Date(), + }) + .where(eq(workspace.id, ctx.workspace.id)) + .returning() + .get(); + + const metadata = input.reason + ? { + reason: input.reason, + from: existing.plan ?? "free", + to: input.plan, + } + : undefined; + + await emitAudit(tx, ctx, { + action: "workspace.update", + entityType: "workspace", + entityId: ctx.workspace.id, + before: existing, + after: updated, + metadata, + }); + }); +}