Merge pull request #26494 from overleaf/kh-fix-trial-transitions

[web] fix upgrades/downgrades while on trial

GitOrigin-RevId: 4076befc5dbbee32b0cf2a4ff99db96a0cf3ad8b
This commit is contained in:
Kristina
2025-06-18 08:06:55 +00:00
committed by Copybot
parent 37e550f43f
commit d6b99c00ee
6 changed files with 120 additions and 14 deletions
@@ -134,9 +134,11 @@ class PaymentProviderSubscription {
if (newPlan == null) {
throw new OError('Unable to find plan in settings', { planCode })
}
const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan
newPlan,
isInTrial
)
const changeRequest = new PaymentProviderSubscriptionChangeRequest({
@@ -250,9 +252,10 @@ class PaymentProviderSubscription {
const addOnUpdates = this.addOns
.filter(addOn => addOn.code !== code)
.map(addOn => addOn.toAddOnUpdate())
const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'term_end',
timeframe: isInTrial ? 'now' : 'term_end',
addOnUpdates,
})
}
@@ -88,9 +88,13 @@ async function userSubscriptionPage(req, res) {
await Modules.promises.hooks.fire('userCanExtendTrial', user)
)?.[0]
const fromPlansPage = req.query.hasSubscription
const isInTrial = SubscriptionHelper.isInTrial(
personalSubscription?.payment?.trialEndsAt
)
const plansData =
SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
personalSubscription?.plan
personalSubscription?.plan,
isInTrial
)
AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
@@ -6,7 +6,12 @@ const { isStandaloneAiAddOnPlanCode } = require('./PaymentProviderEntities')
* If the user changes to a less expensive plan, we shouldn't apply the change immediately.
* This is to avoid unintended/artifical credits on users Recurly accounts.
*/
function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
function shouldPlanChangeAtTermEnd(oldPlan, newPlan, isInTrial) {
if (isInTrial) {
// we should always upgrade or downgrade immediately if actively in trial
return false
}
if (
oldPlan.annual === newPlan.annual &&
isStandaloneAiAddOnPlanCode(oldPlan.planCode) &&
@@ -148,6 +153,14 @@ function getSubscriptionTrialEndsAt(subscription) {
return subscription?.paymentProvider?.trialEndsAt
}
function isInTrial(trialEndsAt) {
if (!trialEndsAt) {
return false
}
return trialEndsAt.getTime() > Date.now()
}
module.exports = {
shouldPlanChangeAtTermEnd,
generateInitialLocalizedGroupPrice,
@@ -157,4 +170,5 @@ module.exports = {
getPaidSubscriptionState,
getSubscriptionTrialStartedAt,
getSubscriptionTrialEndsAt,
isInTrial,
}
@@ -470,7 +470,7 @@ async function getUsersSubscriptionDetails(user) {
return { bestSubscription, individualSubscription, memberGroupSubscriptions }
}
function buildPlansList(currentPlan) {
function buildPlansList(currentPlan, isInTrial) {
const { plans } = Settings
const allPlans = {}
@@ -484,7 +484,11 @@ function buildPlansList(currentPlan) {
result.planCodesChangingAtTermEnd = _.map(
_.filter(plans, plan => {
if (!plan.hideFromUsers) {
return SubscriptionHelper.shouldPlanChangeAtTermEnd(currentPlan, plan)
return SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
plan,
isInTrial
)
}
}),
'planCode'
@@ -569,8 +573,8 @@ function buildGroupSubscriptionForView(groupSubscription) {
}
}
function buildPlansListForSubscriptionDash(currentPlan) {
const allPlansData = buildPlansList(currentPlan)
function buildPlansListForSubscriptionDash(currentPlan, isInTrial) {
const allPlansData = buildPlansList(currentPlan, isInTrial)
const plans = []
// only list individual and visible plans for "change plans" UI
if (allPlansData.studentAccounts) {
@@ -104,6 +104,23 @@ describe('PaymentProviderEntities', function () {
)
})
it('returns a change request for downgrades while on trial', function () {
const fiveDaysFromNow = new Date()
fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
this.subscription.trialPeriodEnd = fiveDaysFromNow
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'cheap-plan',
})
)
})
it('preserves the AI add-on on upgrades', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
@@ -282,6 +299,22 @@ describe('PaymentProviderEntities', function () {
)
})
it('returns a change request when in trial', function () {
const fiveDaysFromNow = new Date()
fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
this.subscription.trialPeriodEnd = fiveDaysFromNow
const changeRequest = this.subscription.getRequestForAddOnRemoval(
this.addOn.code
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [],
})
)
})
it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () {
expect(() =>
this.subscription.getRequestForAddOnRemoval('another-add-on')
@@ -102,38 +102,65 @@ describe('SubscriptionHelper', function () {
})
describe('shouldPlanChangeAtTermEnd', function () {
it('should return true if the new plan is less expensive', function () {
it('should return false if isInTrial is true', function () {
const isInTrial = true
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheaper
plans.cheaper,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return true if the new plan is less expensive', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheaper,
isInTrial
)
expect(changeAtTermEnd).to.be.true
})
it('should return false if the new plan is more exepensive', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.cheaper,
plans.expensive
plans.expensive,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return false if the new plan is the same price', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.cheaper,
plans.alsoCheap
plans.alsoCheap,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return false if the change is from an individual plan to a more expensive group plan', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.expensiveGroup
plans.expensiveGroup,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return true if the change is from an individual plan to a cheaper group plan', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheapGroup
plans.cheapGroup,
isInTrial
)
expect(changeAtTermEnd).to.be.true
})
@@ -471,4 +498,25 @@ describe('SubscriptionHelper', function () {
expect(result).to.be.undefined
})
})
describe('isInTrial', function () {
it('should return false if trialEndsAt is null', function () {
const result = this.SubscriptionHelper.isInTrial(null)
expect(result).to.be.false
})
it('should return false if trialEndsAt is before now', function () {
const tenDaysAgo = new Date()
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
const result = this.SubscriptionHelper.isInTrial(tenDaysAgo)
expect(result).to.be.false
})
it('should return true if trialEndsAt is after now', function () {
const tenDaysFromNow = new Date()
tenDaysFromNow.setDate(tenDaysFromNow.getDate() + 10)
const result = this.SubscriptionHelper.isInTrial(tenDaysFromNow)
expect(result).to.be.true
})
})
})