Skip to content

Commit 0fac24d

Browse files
feat: implement automatic subscription cancellation on last workspace deletion (#18324)
1 parent f70ab6d commit 0fac24d

File tree

4 files changed

+151
-13
lines changed

4 files changed

+151
-13
lines changed

airbyte-webapp/src/core/api/hooks/workspaces.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import {
1313
getWorkspace,
1414
listAccessInfoByWorkspaceId,
1515
listWorkspacesByUser,
16+
listWorkspacesInOrganization,
1617
updateWorkspace,
1718
updateWorkspaceName,
1819
webBackendGetWorkspaceState,
1920
} from "../generated/AirbyteClient";
20-
import { SCOPE_USER, SCOPE_WORKSPACE } from "../scopes";
21+
import { SCOPE_USER, SCOPE_WORKSPACE, SCOPE_ORGANIZATION } from "../scopes";
2122
import {
2223
ConsumptionTimeWindow,
2324
WorkspaceCreate,
@@ -89,7 +90,7 @@ export const useDeleteWorkspace = () => {
8990
const queryClient = useQueryClient();
9091

9192
return useMutation(async (workspaceId: string) => deleteWorkspace({ workspaceId }, requestOptions), {
92-
onSuccess: (_, workspaceId) => {
93+
onSuccess: async (_, workspaceId) => {
9394
queryClient.setQueryData(workspaceKeys.detail(workspaceId), null);
9495
queryClient.resetQueries(organizationKeys.firstWorkspace(organizationId));
9596
queryClient.resetQueries(organizationKeys.workspacesList(organizationId));
@@ -207,6 +208,52 @@ export const useWorkspaceCount = () => {
207208
);
208209
};
209210

211+
/**
212+
* Hook to check if a workspace is the last active workspace in an organization.
213+
* This is useful for billing-related operations like subscription cancellation.
214+
*/
215+
export const useIsLastWorkspaceInOrganization = (
216+
organizationId: string | undefined,
217+
workspaceId: string | undefined
218+
): { isLastWorkspace: boolean; isLoading: boolean; error: Error | null } => {
219+
const requestOptions = useRequestOptions();
220+
221+
// We'll use a query to get all workspaces in the organization and check if the given workspace is the only one
222+
const queryKey = [SCOPE_ORGANIZATION, "checkLastWorkspace", organizationId, workspaceId];
223+
224+
const { data, isLoading, error } = useQuery<boolean, Error>(
225+
queryKey,
226+
async () => {
227+
if (!organizationId || !workspaceId) {
228+
return false;
229+
}
230+
231+
const { workspaces } = await listWorkspacesInOrganization(
232+
{
233+
organizationId,
234+
pagination: { pageSize: 100, rowOffset: 0 },
235+
},
236+
requestOptions
237+
);
238+
239+
// Count active workspaces (those that are not the one being deleted)
240+
const activeWorkspaces = workspaces.filter((ws) => ws.workspaceId !== workspaceId);
241+
242+
return activeWorkspaces.length === 0;
243+
},
244+
{
245+
enabled: Boolean(organizationId && workspaceId),
246+
staleTime: 30000, // 30 seconds
247+
}
248+
);
249+
250+
return {
251+
isLastWorkspace: data ?? false,
252+
isLoading,
253+
error: error ?? null,
254+
};
255+
};
256+
210257
export const useUpdateWorkspace = () => {
211258
const requestOptions = useRequestOptions();
212259
const queryClient = useQueryClient();

airbyte-webapp/src/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2243,6 +2243,9 @@
22432243
"settings.workspaceSettings.delete.confirmation.submitButtonText": "Delete workspace",
22442244
"settings.workspaceSettings.deleteWorkspace.confirmation.title": "Delete workspace: <b>{name}</b>",
22452245
"settings.workspaceSettings.deleteWorkspace.confirmation.text": "Deleting this workspace will remove it for all users and cancel all pending syncs. To confirm deletion, type the name of the workspace in the input field.",
2246+
"settings.workspaceSettings.deleteLastWorkspace.confirmation": "This is your only workspace. If you delete this workspace, you will also cancel your subscription to the <b>{planName}</b> plan. Your last charge for this account will be {date}.",
2247+
"settings.workspaceSettings.delete.success.withSubscriptionCancellation": "Workspace deleted and subscription cancelled successfully",
2248+
"settings.workspaceSettings.delete.warning.subscriptionCancellationFailed": "Workspace deleted successfully, but subscription cancellation failed. Please contact support or cancel your subscription manually from the billing settings.",
22462249
"settings.workspaceSettings.delete.permissionsError": "You do not have sufficient permissions to delete this workspace. Please consult with the workspace owner.",
22472250
"settings.integrationSettings": "Integrations",
22482251
"settings.integrationSettings.dbtCloudSettings": "dbt Cloud Integration",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@use "scss/colors";
2+
@use "scss/variables";
3+
4+
.warningBox {
5+
padding: variables.$spacing-sm variables.$spacing-md;
6+
background-color: colors.$yellow-50;
7+
border-radius: variables.$border-radius-md;
8+
}

airbyte-webapp/src/pages/SettingsPage/components/DeleteWorkspace.tsx

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,104 @@
11
import { FormattedMessage, useIntl } from "react-intl";
22
import { useNavigate } from "react-router-dom";
33

4+
import { Box } from "components/ui/Box";
45
import { Button } from "components/ui/Button";
56

6-
import { useCurrentWorkspace, useDeleteWorkspace } from "core/api";
7+
import { useCurrentOrganizationId } from "area/organization/utils";
8+
import {
9+
useCurrentWorkspace,
10+
useDeleteWorkspace,
11+
useIsLastWorkspaceInOrganization,
12+
useGetOrganizationSubscriptionInfo,
13+
useCancelSubscription,
14+
} from "core/api";
15+
import { OrganizationPaymentConfigReadSubscriptionStatus } from "core/api/types/AirbyteClient";
16+
import { useOrganizationSubscriptionStatus } from "core/utils/useOrganizationSubscriptionStatus";
717
import { useConfirmationModalService } from "hooks/services/ConfirmationModal";
818
import { useNotificationService } from "hooks/services/Notification";
919
import { RoutePaths } from "pages/routePaths";
1020

21+
import styles from "./DeleteWorkspace.module.scss";
22+
1123
export const DeleteWorkspace: React.FC = () => {
1224
const workspace = useCurrentWorkspace();
13-
const { mutateAsync: deleteWorkspace, isLoading: isDeletingWorkspace } = useDeleteWorkspace();
25+
const organizationId = useCurrentOrganizationId();
26+
const { mutateAsync: cancelSubscription, isLoading: isCancellingSubscription } = useCancelSubscription(
27+
organizationId || ""
28+
);
1429
const { registerNotification } = useNotificationService();
1530
const navigate = useNavigate();
16-
const { formatMessage } = useIntl();
31+
const { formatMessage, formatDate } = useIntl();
1732
const { openConfirmationModal, closeConfirmationModal } = useConfirmationModalService();
1833
const redirectPathAfterDeletion = `/${RoutePaths.Organization}/${workspace.organizationId}`;
34+
const { subscriptionStatus, isStandardPlan } = useOrganizationSubscriptionStatus();
35+
36+
// Check if this is the last workspace in the organization
37+
const { isLastWorkspace, isLoading: isCheckingLastWorkspace } = useIsLastWorkspaceInOrganization(
38+
organizationId,
39+
workspace.workspaceId
40+
);
41+
42+
// Fetch subscription info only if this is the last workspace
43+
const { data: subscriptionInfo } = useGetOrganizationSubscriptionInfo(organizationId || "", isLastWorkspace);
44+
const hasActiveSubscription = subscriptionStatus === OrganizationPaymentConfigReadSubscriptionStatus.subscribed;
45+
const shouldShowCancelSubscriptionWarning = isLastWorkspace && hasActiveSubscription && isStandardPlan;
46+
47+
// Handle subscription cancellation after workspace deletion
48+
const handleCancelSubscription = async () => {
49+
// Only attempt subscription cancellation if this is the last workspace, the organization has an active subscription, and is on Standard plan
50+
if (organizationId && shouldShowCancelSubscriptionWarning) {
51+
try {
52+
await cancelSubscription();
53+
registerNotification({
54+
id: "settings.workspace.delete.success",
55+
text: formatMessage({
56+
id: "settings.workspaceSettings.delete.success.withSubscriptionCancellation",
57+
}),
58+
type: "success",
59+
});
60+
} catch (subscriptionError) {
61+
registerNotification({
62+
id: "settings.workspace.delete.subscription.cancellation.failed",
63+
text: formatMessage({
64+
id: "settings.workspaceSettings.delete.warning.subscriptionCancellationFailed",
65+
}),
66+
type: "warning",
67+
});
68+
}
69+
} else {
70+
registerNotification({
71+
id: "settings.workspace.delete.success",
72+
text: formatMessage({ id: "settings.workspaceSettings.delete.success" }),
73+
type: "success",
74+
});
75+
}
76+
};
77+
78+
const { mutateAsync: deleteWorkspace, isLoading: isDeletingWorkspace } = useDeleteWorkspace();
79+
80+
const cancellationDate = subscriptionInfo?.upcomingInvoice?.dueDate
81+
? formatDate(new Date(subscriptionInfo.upcomingInvoice.dueDate), { dateStyle: "medium" })
82+
: "";
83+
84+
const onRemoveWorkspaceClick = () => {
85+
const modalText = shouldShowCancelSubscriptionWarning ? (
86+
<Box className={styles.warningBox}>
87+
<Box pb="md">
88+
<FormattedMessage id="settings.workspaceSettings.deleteWorkspace.confirmation.text" />
89+
</Box>
90+
91+
<FormattedMessage
92+
id="settings.workspaceSettings.deleteLastWorkspace.confirmation"
93+
values={{ planName: subscriptionInfo?.name ?? "", date: cancellationDate }}
94+
/>
95+
</Box>
96+
) : (
97+
"settings.workspaceSettings.deleteWorkspace.confirmation.text"
98+
);
1999

20-
const onRemoveWorkspaceClick = () =>
21100
openConfirmationModal({
22-
text: `settings.workspaceSettings.deleteWorkspace.confirmation.text`,
101+
text: modalText,
23102
title: (
24103
<FormattedMessage
25104
id="settings.workspaceSettings.deleteWorkspace.confirmation.title"
@@ -30,19 +109,20 @@ export const DeleteWorkspace: React.FC = () => {
30109
confirmationText: workspace.name,
31110
onSubmit: async () => {
32111
await deleteWorkspace(workspace.workspaceId);
33-
registerNotification({
34-
id: "settings.workspace.delete.success",
35-
text: formatMessage({ id: "settings.workspaceSettings.delete.success" }),
36-
type: "success",
37-
});
112+
await handleCancelSubscription();
38113
navigate(redirectPathAfterDeletion);
39114
closeConfirmationModal();
40115
},
41116
submitButtonDataId: "reset",
42117
});
118+
};
43119

44120
return (
45-
<Button isLoading={isDeletingWorkspace} variant="danger" onClick={onRemoveWorkspaceClick}>
121+
<Button
122+
isLoading={isCheckingLastWorkspace || isDeletingWorkspace || isCancellingSubscription}
123+
variant="danger"
124+
onClick={onRemoveWorkspaceClick}
125+
>
46126
<FormattedMessage id="settings.workspaceSettings.deleteLabel" />
47127
</Button>
48128
);

0 commit comments

Comments
 (0)