Merge pull request #27643 from overleaf/rh-pause-cancel

Terminate Recurly subscription when cancelling during final month of pause

GitOrigin-RevId: 39e4c9534621f57b3e2783599ebe521959d7401f
This commit is contained in:
roo hutton
2025-08-28 10:25:06 +01:00
committed by Copybot
parent 3279f997bb
commit 467102fd1b
5 changed files with 61 additions and 66 deletions

View File

@@ -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
}
}

View File

@@ -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
)
}
}

View File

@@ -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)

View File

@@ -516,35 +516,6 @@ describe('<ActiveSubscription />', 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)

View File

@@ -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 () {