From 467102fd1b375cec237feab4bd5e32076ae797fa Mon Sep 17 00:00:00 2001 From: roo hutton Date: Thu, 28 Aug 2025 10:25:06 +0100 Subject: [PATCH] Merge pull request #27643 from overleaf/rh-pause-cancel Terminate Recurly subscription when cancelling during final month of pause GitOrigin-RevId: 39e4c9534621f57b3e2783599ebe521959d7401f --- .../Features/Subscription/RecurlyClient.js | 34 +++++++++++++------ .../Subscription/SubscriptionHandler.js | 31 +++++++---------- .../active/cancel-subscription-button.tsx | 9 +---- .../dashboard/states/active/active.test.tsx | 29 ---------------- .../src/Subscription/RecurlyClientTests.js | 24 +++++++++++++ 5 files changed, 61 insertions(+), 66 deletions(-) diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 076b2a2514..610d932aa8 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -331,18 +331,32 @@ async function cancelSubscriptionByUuid(subscriptionUuid) { try { return await client.cancelSubscription('uuid-' + subscriptionUuid) } catch (err) { - if (err instanceof recurly.errors.ValidationError) { - if ( - err.message === 'Only active and future subscriptions can be canceled.' - ) { - logger.debug( - { subscriptionUuid }, - 'subscription cancellation failed, subscription not active' - ) - } - } else { + if (!(err instanceof recurly.errors.ValidationError)) { throw err } + + const errorMessage = err.message || '' + + if ( + errorMessage === 'Only active and future subscriptions can be canceled.' + ) { + logger.debug( + { subscriptionUuid }, + 'subscription cancellation failed, subscription not active' + ) + } else if ( + errorMessage.includes( + 'Cannot cancel a paused subscription in the last cycle of the term' + ) + ) { + logger.debug( + { subscriptionUuid }, + 'Terminating subscription in last cycle of paused term' + ) + return await terminateSubscriptionByUuid(subscriptionUuid) + } + + throw err } } diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 4f3b1ac978..4386ad994b 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -134,26 +134,19 @@ async function cancelPendingSubscriptionChange(user) { * @param user */ async function cancelSubscription(user) { - try { - const { hasSubscription, subscription } = - await LimitationsManager.promises.userHasSubscription(user) - if (hasSubscription && subscription != null) { - await Modules.promises.hooks.fire('cancelPaidSubscription', subscription) - const emailOpts = { - to: user.email, - first_name: user.first_name, - } - const ONE_HOUR_IN_MS = 1000 * 60 * 60 - EmailHandler.sendDeferredEmail( - 'canceledSubscription', - emailOpts, - ONE_HOUR_IN_MS - ) + const { hasSubscription, subscription } = + await LimitationsManager.promises.userHasSubscription(user) + if (hasSubscription && subscription != null) { + await Modules.promises.hooks.fire('cancelPaidSubscription', subscription) + const emailOpts = { + to: user.email, + first_name: user.first_name, } - } catch (err) { - logger.warn( - { err, userId: user._id }, - 'there was an error checking user v2 subscription' + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + EmailHandler.sendDeferredEmail( + 'canceledSubscription', + emailOpts, + ONE_HOUR_IN_MS ) } } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx index fc98cf2d9b..1e82e22d71 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx @@ -4,7 +4,6 @@ import { useSubscriptionDashboardContext } from '../../../../context/subscriptio import OLButton from '@/shared/components/ol/ol-button' import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription' import { useFeatureFlag } from '@/shared/context/split-test-context' -import { useLocation } from '@/shared/hooks/use-location' export function CancelSubscriptionButton() { const { t } = useTranslation() @@ -14,7 +13,6 @@ export function CancelSubscriptionButton() { setModalIdShown, setShowCancellation, } = useSubscriptionDashboardContext() - const location = useLocation() const subscription = personalSubscription as PaidSubscription const isInTrial = @@ -31,18 +29,13 @@ export function CancelSubscriptionButton() { useFeatureFlag('pause-subscription') && !hasPendingOrActivePause && planIsEligibleForPause - const shouldContactSupport = - subscription.payment.state === 'paused' && - subscription.payment.remainingPauseCycles === 0 function handleCancelSubscriptionClick() { eventTracking.sendMB('subscription-page-cancel-button-click', { plan_code: subscription?.planCode, is_trial: isInTrial, }) - if (shouldContactSupport) { - location.assign('/contact') - } else if (enablePause) { + if (enablePause) { setModalIdShown('pause-subscription') } else { setShowCancellation(true) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx index f5802a2368..baada41976 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx @@ -516,35 +516,6 @@ describe('', function () { }) }) - describe('contact support for paused subscription with 0 remaining cycles', function () { - beforeEach(function () { - this.locationWrapperSandbox = sinon.createSandbox() - this.locationWrapperStub = this.locationWrapperSandbox.stub(location) - }) - - afterEach(function () { - this.locationWrapperSandbox.restore() - }) - - it('redirects to contact page when cancel button clicked', function () { - const pausedSubscription = cloneDeep(annualActiveSubscription) - pausedSubscription.payment.state = 'paused' - pausedSubscription.payment.remainingPauseCycles = 0 - - renderActiveSubscription(pausedSubscription) - - const button = screen.getByRole('button', { - name: 'Cancel your subscription', - }) - fireEvent.click(button) - - expect(sendMBSpy).to.be.calledOnceWith( - 'subscription-page-cancel-button-click' - ) - expect(this.locationWrapperStub.assign).to.be.calledOnceWith('/contact') - }) - }) - describe('group plans', function () { it('does not show "Change plan" option for group plans', function () { renderActiveSubscription(groupActiveSubscription) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 2838cff167..798da8d29f 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -560,6 +560,30 @@ describe('RecurlyClient', function () { 'uuid-' + this.subscription.uuid ) }) + + it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function () { + const validationError = new recurly.errors.ValidationError() + validationError.message = + 'Cannot cancel a paused subscription in the last cycle of the term' + + this.client.cancelSubscription = sinon.stub().throws(validationError) + this.client.terminateSubscription = sinon + .stub() + .resolves(this.recurlySubscription) + + const subscription = + await this.RecurlyClient.promises.cancelSubscriptionByUuid( + this.subscription.uuid + ) + + expect(this.client.cancelSubscription).to.be.calledWith( + 'uuid-' + this.subscription.uuid + ) + expect(this.client.terminateSubscription).to.be.calledWith( + 'uuid-' + this.subscription.uuid + ) + expect(subscription).to.deep.equal(this.recurlySubscription) + }) }) describe('pauseSubscriptionByUuid', function () {