From 8cb07fdb08850649150a22a4e35785b337b9b151 Mon Sep 17 00:00:00 2001 From: roo hutton Date: Wed, 23 Jul 2025 12:16:19 +0100 Subject: [PATCH] Merge pull request #27141 from overleaf/rh-stripe-schedule-pause Support subscription pausing in Stripe GitOrigin-RevId: 5550b2af2db99fd456d591c9bb4ba64d34dc7615 --- .../Subscription/SubscriptionHandler.js | 5 +- .../SubscriptionViewModelBuilder.js | 35 ++++++++++--- .../Subscription/SubscriptionHandlerTests.js | 13 +++-- .../SubscriptionViewModelBuilderTests.js | 52 ++++++++++++++++++- 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 656b57aa01..0ba485ca75 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -319,8 +319,9 @@ async function pauseSubscription(user, pauseCycles) { throw new Error('Cannot pause a subscription with addons') } - await RecurlyClient.promises.pauseSubscriptionByUuid( - subscription.recurlySubscription_id, + await Modules.promises.hooks.fire( + 'pausePaidSubscription', + subscription, pauseCycles ) } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 9672af6cd5..21a7e4906d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -17,6 +17,7 @@ const { V1ConnectionError } = require('../Errors/Errors') const FeaturesHelper = require('./FeaturesHelper') const { formatCurrency } = require('../../util/currency') const Modules = require('../../infrastructure/Modules') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') /** * @import { Subscription } from "../../../../types/project/dashboard/subscription" @@ -254,6 +255,32 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { const isInTrial = paymentRecord.subscription.trialPeriodEnd && paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now() + + let isEligibleForPause = false + const commonPauseConditions = + !personalSubscription.pendingPlan && + !personalSubscription.groupPlan && + !isInTrial && + !paymentRecord.subscription.planCode.includes('ann') && + !paymentRecord.subscription.addOns?.length + + if ( + paymentRecord.subscription.service === 'recurly' && + commonPauseConditions + ) { + isEligibleForPause = true + } else if ( + paymentRecord.subscription.service.includes('stripe') && + commonPauseConditions + ) { + const stripePauseAssignment = + await SplitTestHandler.promises.getAssignmentForUser( + user._id, + 'stripe-pause' + ) + isEligibleForPause = stripePauseAssignment.variant === 'enabled' + } + personalSubscription.payment = { taxRate, billingDetailsLink: @@ -281,13 +308,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice, pausedAt: paymentRecord.subscription.pausePeriodStart, remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles, - isEligibleForPause: - paymentRecord.subscription.service === 'recurly' && - !personalSubscription.pendingPlan && - !personalSubscription.groupPlan && - !isInTrial && - !paymentRecord.subscription.planCode.includes('ann') && - !paymentRecord.subscription.addOns?.length > 0, + isEligibleForPause, isEligibleForGroupPlan: await isEligibleForGroupPlan( paymentRecord.subscription.service, isInTrial diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index 7bf23defd2..a6d1201d47 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -497,11 +497,18 @@ describe('SubscriptionHandler', function () { }, }) }) - it('should make a pause call to recurly', async function () { + it('should call pause hook', async function () { await this.SubscriptionHandler.promises.pauseSubscription(this.user, 3) - this.RecurlyClient.promises.pauseSubscriptionByUuid.called.should.equal( - true + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'pausePaidSubscription', + { + recurlySubscription_id: this.activeRecurlySubscription.uuid, + recurlyStatus: { state: 'non-trial' }, + planCode: 'collaborator', + addOns: [], + }, + 3 ) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index 235abf600e..38c34062ca 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -704,8 +704,58 @@ describe('SubscriptionViewModelBuilder', function () { }) describe('isEligibleForPause', function () { - it('is false for Stripe subscriptions', async function () { + beforeEach(function () { + this.paymentRecord.service = 'recurly' + this.paymentRecord.addOns = [] + this.paymentRecord.planCode = 'plan-code' + this.paymentRecord.trialPeriodEnd = null + this.individualSubscription.pendingPlan = undefined + this.individualSubscription.groupPlan = undefined + }) + + it('is false for Stripe subscriptions when feature flag is disabled', async function () { this.paymentRecord.service = 'stripe-us' + this.SplitTestHandler.promises.getAssignmentForUser + .withArgs(this.user._id, 'stripe-pause') + .resolves({ variant: 'default' }) + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is true for Stripe subscriptions when feature flag is enabled', async function () { + this.paymentRecord.service = 'stripe-us' + this.SplitTestHandler.promises.getAssignmentForUser + .withArgs(this.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isTrue(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function () { + this.paymentRecord.service = 'stripe-us' + this.individualSubscription.pendingPlan = {} // anything + this.SplitTestHandler.promises.getAssignmentForUser + .withArgs(this.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function () { + this.paymentRecord.service = 'stripe-us' + this.paymentRecord.planCode = 'collaborator-annual' + this.SplitTestHandler.promises.getAssignmentForUser + .withArgs(this.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) const result = await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( this.user