diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index f348c18ec5..8ac55636ed 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -20,6 +20,7 @@ import { HttpCode, HttpStatus, Logger, + Delete, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ApiExcludeController } from "@nestjs/swagger"; @@ -96,6 +97,19 @@ export class BillingController { }; } + @Delete("/:organizationId/unsubscribe") + @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @MembershipRoles(["OWNER", "ADMIN"]) + async cancelTeamSubscriptionInStripe( + @Param("organizationId") organizationId: number + ): Promise { + await this.billingService.cancelTeamSubscription(organizationId); + + return { + status: "success", + }; + } + @Post("/webhook") @HttpCode(HttpStatus.OK) async stripeWebhook( diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 56c12584c0..55812befbc 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -7,6 +7,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat import { StripeService } from "@/modules/stripe/stripe.service"; import { InjectQueue } from "@nestjs/bull"; import { + BadRequestException, Injectable, InternalServerErrorException, Logger, @@ -313,6 +314,28 @@ export class BillingService implements OnModuleDestroy { } } + async cancelTeamSubscription(teamId: number) { + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + const customerId = teamWithBilling?.platformBilling?.customerId; + + if (!customerId) { + throw new NotFoundException("No customer id found for team in Stripe"); + } + + if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) { + throw new NotFoundException("Team plan not found"); + } + + try { + await this.stripeService + .getStripe() + .subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId); + } catch (error) { + this.logger.log(error, "error while cancelling team subscription in stripe"); + throw new BadRequestException("Failed to cancel team subscription"); + } + } + async onModuleDestroy() { try { await this.billingQueue.close(); diff --git a/apps/web/lib/hooks/settings/platform/billing/useUnsubscribeTeamToStripe.ts b/apps/web/lib/hooks/settings/platform/billing/useUnsubscribeTeamToStripe.ts new file mode 100644 index 0000000000..71c8c98c93 --- /dev/null +++ b/apps/web/lib/hooks/settings/platform/billing/useUnsubscribeTeamToStripe.ts @@ -0,0 +1,36 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse } from "@calcom/platform-types"; + +export const useUnsubscribeTeamToStripe = ( + { onSuccess, onError, teamId }: { teamId?: number | null; onSuccess: () => void; onError: () => void } = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const mutation = useMutation({ + mutationFn: (data) => { + return fetch(`/api/v2/billing/${teamId}/unsubscribe`, { + method: "delete", + headers: { "Content-type": "application/json" }, + }).then((res) => res?.json()); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(); + } else { + onError?.(); + } + }, + onError: () => { + onError?.(); + }, + }); + + return mutation; +}; diff --git a/apps/web/modules/settings/platform/billing/billing-view.tsx b/apps/web/modules/settings/platform/billing/billing-view.tsx index 951a7e8e26..4eef149b56 100644 --- a/apps/web/modules/settings/platform/billing/billing-view.tsx +++ b/apps/web/modules/settings/platform/billing/billing-view.tsx @@ -2,12 +2,17 @@ import { usePathname } from "next/navigation"; +import { Dialog } from "@calcom/features/components/controlled-dialog"; import Shell from "@calcom/features/shell/Shell"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; +import { DialogTrigger, ConfirmationDialogContent } from "@calcom/ui/components/dialog"; +import { showToast } from "@calcom/ui/components/toast"; import { PlatformPricing } from "@calcom/web/components/settings/platform/pricing/platform-pricing/index"; +import { useUnsubscribeTeamToStripe } from "@lib/hooks/settings/platform/billing/useUnsubscribeTeamToStripe"; + import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPlan"; import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes"; @@ -37,6 +42,18 @@ export default function PlatformBillingUpgrade() { const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } = useGetUserAttributes(); + const { mutateAsync: removeTeamSubscription, isPending: isRemoveTeamSubscriptionLoading } = + useUnsubscribeTeamToStripe({ + onSuccess: () => { + window.location.href = "/settings/platform/"; + showToast(t("team_subscription_cancelled_successfully"), "success"); + }, + onError: () => { + showToast(t("team_subscription_cancellation_error"), "error"); + }, + teamId: userOrgId, + }); + if (isUserLoading || (isUserBillingDataLoading && !userBillingData)) { return
{t("loading")}
; } @@ -92,6 +109,18 @@ export default function PlatformBillingUpgrade() {
+ + { + removeTeamSubscription(); + }} + /> + + +
+ + + + { + handleDelete(); + onDeleteConfirmed?.(); + }}> + {t("cancel_subscription_description")} + + + ); +}; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 32cc1fee5e..27f6b51fb9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -725,6 +725,9 @@ "remove_member_confirmation_message": "Are you sure you want to remove this member from the team?", "confirm_disband_team": "Yes, disband team", "confirm_remove_member": "Yes, remove member", + "confirm_subscription_cancellation": "Yes, cancel subscription", + "cancel_subscription": "Cancel subscription", + "cancel_subscription_description": "Cancelling your subscription will permanently deactivate your current plan. This action cannot be undone.", "remove_member": "Remove member", "manage_your_team": "Manage your team", "no_teams": "You don't have any teams yet.", @@ -2681,6 +2684,8 @@ "proceeding_agreement": "By proceeding, you agree to our", "terms": "Terms", "always_show_x_days": "Always {{x}} days available", + "team_subscription_cancelled_successfully": "Team subscription cancelled successfully", + "team_subscription_cancellation_error": "Error while cancelling the team subscription", "unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later", "updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later", "creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later",