Skip to content

Commit 480ca8e

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
fix(webapp): cancel the billing subscription when deleting an organization
Deleting an organization now cancels its billing subscription immediately (one final invoice for consumed usage) before the organization is removed, warns about this in the delete dialog, and refuses deletion when the billing state cannot be verified. Mono-RevId: 828f4b59737e6c36cd0b58387aa33f92600cca4e
1 parent 485ab83 commit 480ca8e

7 files changed

Lines changed: 216 additions & 35 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting an organization now cancels its billing subscription.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives
4444
import { SpinnerWhite } from "~/components/primitives/Spinner";
4545
import { prisma } from "~/db.server";
4646
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
47+
import { useFeatures } from "~/hooks/useFeatures";
4748
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
4849
import { resolveOrgIdFromSlug } from "~/models/organization.server";
4950
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
@@ -336,6 +337,7 @@ export const action = dashboardAction(
336337

337338
export default function Page() {
338339
const { organization, canManageOrganization } = useTypedLoaderData<typeof loader>();
340+
const { isManagedCloud } = useFeatures();
339341
const lastSubmission = useActionData();
340342
const navigation = useNavigation();
341343

@@ -453,6 +455,8 @@ export default function Page() {
453455
This change is irreversible, so please be certain. Type in the Organization
454456
slug <InlineCode variant="extra-small">{organization.slug}</InlineCode> and
455457
then press Delete.
458+
{isManagedCloud &&
459+
" Your subscription will be cancelled immediately and any outstanding usage will be billed."}
456460
</Hint>
457461
</InputGroup>
458462
<FormButtons

‎apps/webapp/app/services/deleteOrganization.server.ts‎

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { DateFormatter } from "@internationalized/date";
1+
import { tryCatch } from "@trigger.dev/core/v3";
22
import type { PrismaClient } from "@trigger.dev/database";
33
import { prisma } from "~/db.server";
4-
import { featuresForRequest } from "~/features.server";
54
import { DeleteProjectService } from "./deleteProject.server";
6-
import { getCurrentPlan } from "./platform.v3.server";
5+
import { cancelSubscription, getCurrentPlanOrThrow } from "./platform.v3.server";
76
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
87
import { commonWorker } from "~/v3/commonWorker.server";
98
import { logger } from "./logger.server";
@@ -18,7 +17,6 @@ export class DeleteOrganizationService {
1817
public async call({
1918
organizationSlug,
2019
userId,
21-
request,
2220
}: {
2321
organizationSlug: string;
2422
userId: string;
@@ -43,31 +41,7 @@ export class DeleteOrganizationService {
4341
throw new Error("Organization already deleted");
4442
}
4543

46-
//Check if they have an active subscription
47-
const { isManagedCloud } = featuresForRequest(request);
48-
const currentPlan = isManagedCloud ? await getCurrentPlan(organization.id) : undefined;
49-
50-
if (currentPlan && currentPlan.v3Subscription && currentPlan.v3Subscription.isPaying) {
51-
//they've cancelled and that date hasn't passed yet
52-
if (
53-
currentPlan.v3Subscription.canceledAt &&
54-
new Date(currentPlan.v3Subscription.canceledAt) > new Date()
55-
) {
56-
//a dateformatter that produces results like "Jan 1 2024"
57-
const dateFormatter = new DateFormatter("en-us", {
58-
year: "numeric",
59-
month: "short",
60-
day: "numeric",
61-
});
62-
throw new Error(
63-
`This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
64-
new Date(currentPlan.v3Subscription.canceledAt)
65-
)}) is in the past.`
66-
);
67-
}
68-
69-
throw new Error("You can't delete an Organization that has an active subscription");
70-
}
44+
await this.#cancelSubscription(organization.id);
7145

7246
// loop through the projects and delete them
7347
const projectDeleteService = new DeleteProjectService();
@@ -105,4 +79,26 @@ export class DeleteOrganizationService {
10579
});
10680
}
10781
}
82+
83+
async #cancelSubscription(organizationId: string) {
84+
let plan: Awaited<ReturnType<typeof getCurrentPlanOrThrow>>;
85+
try {
86+
plan = await getCurrentPlanOrThrow(organizationId);
87+
} catch (error) {
88+
logger.error("Failed to load plan before deleting organization", { organizationId, error });
89+
throw new Error("Couldn't verify billing state, try again");
90+
}
91+
92+
if (!plan) return;
93+
94+
const [error, result] = await tryCatch(cancelSubscription(organizationId));
95+
if (error || !result?.success) {
96+
logger.error("Failed to cancel subscription before deleting organization", {
97+
organizationId,
98+
error,
99+
result,
100+
});
101+
throw new Error("Couldn't cancel the subscription, try again");
102+
}
103+
}
108104
}

‎apps/webapp/app/services/platform.v3.server.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,18 @@ export async function getCurrentPlan(orgId: string) {
395395
}
396396
}
397397

398+
export async function getCurrentPlanOrThrow(orgId: string) {
399+
if (!client) return undefined;
400+
401+
const result = await client.currentPlan(orgId);
402+
if (!result.success) {
403+
recordPlatformFailure("getCurrentPlanOrThrow", "no_success");
404+
throw new Error(result.error);
405+
}
406+
407+
return result;
408+
}
409+
398410
export type SelfServePurchaseBlockReason = "plan_unavailable" | "managed_billing";
399411

400412
/**
@@ -620,6 +632,24 @@ export async function activateFreePlan(
620632
return { success: true };
621633
}
622634

635+
export async function cancelSubscription(orgId: string) {
636+
if (!client) return undefined;
637+
638+
const [error, result] = await tryCatch(client.cancelSubscription(orgId));
639+
if (error) {
640+
recordPlatformFailure("cancelSubscription", "caught");
641+
throw error;
642+
}
643+
644+
if (result.success) {
645+
invalidatePlanDerivedCaches(orgId);
646+
} else {
647+
recordPlatformFailure("cancelSubscription", "no_success");
648+
}
649+
650+
return result;
651+
}
652+
623653
export async function setPlan(
624654
organization: { id: string; slug: string },
625655
request: Request,

‎apps/webapp/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
"@trigger.dev/core": "workspace:*",
131131
"@trigger.dev/database": "workspace:*",
132132
"@trigger.dev/otlp-importer": "workspace:*",
133-
"@trigger.dev/platform": "1.3.1",
133+
"@trigger.dev/platform": "1.4.0",
134134
"@trigger.dev/plugins": "workspace:*",
135135
"@trigger.dev/rbac": "workspace:*",
136136
"@trigger.dev/redis-worker": "workspace:*",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import type { PrismaClient } from "@trigger.dev/database";
3+
import { beforeEach, describe, expect, vi } from "vitest";
4+
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
5+
import { createTestUser, uniqueId } from "./fixtures/environmentVariablesFixtures";
6+
7+
const { getCurrentPlanOrThrowMock, cancelSubscriptionMock } = vi.hoisted(() => ({
8+
getCurrentPlanOrThrowMock: vi.fn(async (_orgId: string): Promise<unknown> => undefined),
9+
cancelSubscriptionMock: vi.fn(async (_orgId: string): Promise<unknown> => undefined),
10+
}));
11+
12+
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
13+
const mod = (await importOriginal()) as Record<string, unknown>;
14+
return {
15+
...mod,
16+
getCurrentPlanOrThrow: getCurrentPlanOrThrowMock,
17+
cancelSubscription: cancelSubscriptionMock,
18+
};
19+
});
20+
21+
vi.mock("~/v3/commonWorker.server", () => ({
22+
commonWorker: { enqueue: async () => undefined },
23+
}));
24+
25+
vi.setConfig({ testTimeout: 60_000 });
26+
27+
async function createOrg(prisma: PrismaClient) {
28+
const user = await createTestUser(prisma);
29+
const organization = await prisma.organization.create({
30+
data: {
31+
title: "Test Org",
32+
slug: uniqueId("org"),
33+
members: { create: { userId: user.id, role: "ADMIN" } },
34+
},
35+
});
36+
return { user, organization };
37+
}
38+
39+
async function deleteOrg(prisma: PrismaClient, slug: string, userId: string) {
40+
return new DeleteOrganizationService(prisma).call({
41+
organizationSlug: slug,
42+
userId,
43+
request: new Request("https://cloud.trigger.dev/"),
44+
});
45+
}
46+
47+
function plan(v3Subscription: Record<string, unknown>) {
48+
return { success: true, v3Subscription: { freeTierStatus: "approved", ...v3Subscription } };
49+
}
50+
51+
describe("DeleteOrganizationService billing", () => {
52+
beforeEach(() => {
53+
getCurrentPlanOrThrowMock.mockReset();
54+
cancelSubscriptionMock.mockReset();
55+
});
56+
57+
postgresTest("aborts when the plan lookup fails", async ({ prisma }) => {
58+
const { user, organization } = await createOrg(prisma);
59+
getCurrentPlanOrThrowMock.mockRejectedValue(new Error("billing down"));
60+
61+
await expect(deleteOrg(prisma, organization.slug, user.id)).rejects.toThrow(
62+
"Couldn't verify billing state, try again"
63+
);
64+
65+
expect(cancelSubscriptionMock).not.toHaveBeenCalled();
66+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
67+
expect(org.deletedAt).toBeNull();
68+
});
69+
70+
postgresTest("cancels a paid subscription then deletes", async ({ prisma }) => {
71+
const { user, organization } = await createOrg(prisma);
72+
getCurrentPlanOrThrowMock.mockResolvedValue(plan({ isPaying: true }));
73+
cancelSubscriptionMock.mockResolvedValue({
74+
success: true,
75+
outcome: "canceled",
76+
cancelAt: null,
77+
});
78+
79+
await deleteOrg(prisma, organization.slug, user.id);
80+
81+
expect(cancelSubscriptionMock).toHaveBeenCalledTimes(1);
82+
expect(cancelSubscriptionMock).toHaveBeenCalledWith(organization.id);
83+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
84+
expect(org.deletedAt).toBeInstanceOf(Date);
85+
});
86+
87+
postgresTest("aborts when the cancel call returns an error", async ({ prisma }) => {
88+
const { user, organization } = await createOrg(prisma);
89+
getCurrentPlanOrThrowMock.mockResolvedValue(plan({ isPaying: true }));
90+
cancelSubscriptionMock.mockResolvedValue({ success: false, error: "stripe_error" });
91+
92+
await expect(deleteOrg(prisma, organization.slug, user.id)).rejects.toThrow(
93+
"Couldn't cancel the subscription, try again"
94+
);
95+
96+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
97+
expect(org.deletedAt).toBeNull();
98+
});
99+
100+
postgresTest("aborts when the cancel call throws", async ({ prisma }) => {
101+
const { user, organization } = await createOrg(prisma);
102+
getCurrentPlanOrThrowMock.mockResolvedValue(plan({ isPaying: true }));
103+
cancelSubscriptionMock.mockRejectedValue(new Error("network down"));
104+
105+
await expect(deleteOrg(prisma, organization.slug, user.id)).rejects.toThrow(
106+
"Couldn't cancel the subscription, try again"
107+
);
108+
109+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
110+
expect(org.deletedAt).toBeNull();
111+
});
112+
113+
postgresTest("deletes when the subscription is already canceled", async ({ prisma }) => {
114+
const { user, organization } = await createOrg(prisma);
115+
getCurrentPlanOrThrowMock.mockResolvedValue(plan({ isPaying: true }));
116+
cancelSubscriptionMock.mockResolvedValue({
117+
success: true,
118+
outcome: "already_canceled",
119+
cancelAt: null,
120+
});
121+
122+
await deleteOrg(prisma, organization.slug, user.id);
123+
124+
expect(cancelSubscriptionMock).toHaveBeenCalledTimes(1);
125+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
126+
expect(org.deletedAt).toBeInstanceOf(Date);
127+
});
128+
129+
postgresTest("asks billing to cancel even when not paying", async ({ prisma }) => {
130+
const { user, organization } = await createOrg(prisma);
131+
getCurrentPlanOrThrowMock.mockResolvedValue(plan({ isPaying: false }));
132+
cancelSubscriptionMock.mockResolvedValue({
133+
success: true,
134+
outcome: "no_subscription",
135+
cancelAt: null,
136+
});
137+
138+
await deleteOrg(prisma, organization.slug, user.id);
139+
140+
expect(cancelSubscriptionMock).toHaveBeenCalledTimes(1);
141+
expect(cancelSubscriptionMock).toHaveBeenCalledWith(organization.id);
142+
const org = await prisma.organization.findUniqueOrThrow({ where: { id: organization.id } });
143+
expect(org.deletedAt).toBeInstanceOf(Date);
144+
});
145+
});

‎pnpm-lock.yaml‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)