Merge pull request #35836 from overleaf/rh-prices-split

[web] Update monthly plan prices for new subscriptions with split test

GitOrigin-RevId: 42d0adaf68647adc93f4c05d5ddeefb356753db2
This commit is contained in:
roo hutton
2026-09-17 11:49:04 +00:00
committed by Copybot
parent 2e1a313ccd
commit b886c0b266
18 changed files with 553 additions and 30 deletions
@@ -61,6 +61,37 @@ describe('<ChangePlanModal />', function () {
expect(screen.queryByText('loading', { exact: false })).to.be.null
})
it('shows the price of the assigned price version for Stripe subscriptions', async function () {
const stripeSubscription = {
...annualActiveSubscription,
service: 'stripe-us' as const,
}
// the backend resolves listPrice from the user's assigned price version;
// the Recurly stub prices this plan at $23.00
const plansWithListPrice = plans.map(plan => ({
...plan,
displayPrice: undefined,
...(plan.planCode === 'collaborator' ? { listPrice: 25 } : {}),
}))
renderWithSubscriptionDashContext(
<ActiveSubscription subscription={stripeSubscription} />,
{
metaTags: [
{ name: 'ol-plans', value: plansWithListPrice },
{ name: 'ol-groupPlans', value: groupPlans },
{ name: 'ol-subscription', value: stripeSubscription },
{ name: 'ol-recommendedCurrency', value: 'USD' },
],
}
)
fireEvent.click(screen.getByRole('button', { name: 'Change plan' }))
const priceCell = await screen.findByText('$25.00 / month')
const row = priceCell.closest('tr') as HTMLTableRowElement
within(row).getByText('Standard monthly')
})
it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', async function () {
renderActiveSubscription(pendingSubscriptionChange)
@@ -49,6 +49,7 @@ export const annualActiveSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -93,6 +94,7 @@ export const pastDueActiveSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: true,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -140,6 +142,7 @@ export const annualActiveSubscriptionWithCoupons: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -185,6 +188,7 @@ export const pendingPausedSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -230,6 +234,7 @@ export const pausedSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -274,6 +279,7 @@ export const annualActiveSubscriptionWithAddons: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {
@@ -321,6 +327,7 @@ export const annualActiveSubscriptionEuro: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '€221.96',
planPrice: 179,
planOnlyDisplayPrice: '€221.96',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -364,6 +371,7 @@ export const annualActiveSubscriptionPro: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$42.00',
planPrice: 42,
planOnlyDisplayPrice: '$42.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -408,6 +416,7 @@ export const pastDueExpiredSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: true,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -452,6 +461,7 @@ export const canceledSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -496,6 +506,7 @@ export const pendingAddOnChange: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [
{
@@ -554,6 +565,7 @@ export const pendingSubscriptionChange: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$199.00',
planPrice: 199,
planOnlyDisplayPrice: '$199.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -609,6 +621,7 @@ export const groupActiveSubscription: GroupSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$1290.00',
planPrice: 1290,
planOnlyDisplayPrice: '$1290.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -657,6 +670,7 @@ export const groupProfessionalActiveSubscription: GroupSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$1290.00',
planPrice: 1290,
planOnlyDisplayPrice: '$1290.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -706,6 +720,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$2967.00',
planPrice: 1290,
pendingAdditionalLicenses: 13,
pendingTotalLicenses: 23,
planOnlyDisplayPrice: '$2967.00',
@@ -761,6 +776,7 @@ export const trialSubscription: PaidSubscription = {
accountEmail: 'fake@example.com',
hasPastDueInvoice: false,
displayPrice: '$14.00',
planPrice: 14,
planOnlyDisplayPrice: '$14.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -825,6 +841,7 @@ export const trialCollaboratorSubscription: PaidSubscription = {
accountEmail: 'foo@example.com',
hasPastDueInvoice: false,
displayPrice: '$21.00',
planPrice: 21,
planOnlyDisplayPrice: '$21.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -868,6 +885,7 @@ export const monthlyActiveCollaborator: PaidSubscription = {
accountEmail: 'foo@example.com',
hasPastDueInvoice: false,
displayPrice: '$21.00',
planPrice: 21,
planOnlyDisplayPrice: '$21.00',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { roundedTwelfth } from '../../../../app/src/Features/Subscription/PlansHelper.mjs'
import {
annualSavings,
roundedTwelfth,
} from '../../../../app/src/Features/Subscription/PlansHelper.mjs'
describe('PlansHelper', function () {
describe('roundedTwelfth', function () {
@@ -14,5 +17,39 @@ describe('PlansHelper', function () {
// 505 / 12 = 42.0833… -> 42.1
expect(roundedTwelfth(505)).to.equal(42.1)
})
it('rounds up to the nearest 0.25 when given a 0.25 increment', function () {
// 159 / 12 = 13.25 -> already a multiple of 0.25
expect(roundedTwelfth(159, 0.25)).to.equal(13.25)
// 76 / 12 = 6.3333… -> 6.5
expect(roundedTwelfth(76, 0.25)).to.equal(6.5)
// 319 / 12 = 26.5833… -> 26.75
expect(roundedTwelfth(319, 0.25)).to.equal(26.75)
})
})
describe('annualSavings', function () {
it('gives the saving against a year of monthly billing', function () {
// 120/year against 20/month, which would be 240
expect(annualSavings(20, 120)).to.equal(0.5)
// 100/year against 10/month, which would be 120
expect(annualSavings(10, 100)).to.be.closeTo(1 / 6, 1e-9)
})
it('ignores the rounding applied to the displayed per-month price', function () {
// 100/year displays as 8.35 or 8.50 a month depending on the increment;
// rounding up understates the saving, so neither stands in for it
expect(annualSavings(10, 100)).to.be.greaterThan(
1 - roundedTwelfth(100, 0.05) / 10
)
expect(annualSavings(10, 100)).to.be.greaterThan(
1 - roundedTwelfth(100, 0.25) / 10
)
})
it('is not positive when annual billing saves nothing', function () {
expect(annualSavings(10, 120)).to.equal(0)
expect(annualSavings(10, 132)).to.be.lessThan(0)
})
})
})
@@ -27,7 +27,7 @@ const plans = [
describe('PlansLocator', function () {
beforeEach(async function (ctx) {
ctx.settings = { plans }
ctx.settings = { plans, additionalPriceVersions: ['jan2099'] }
ctx.AI_ADD_ON_CODE = 'assistant'
vi.doMock('@overleaf/settings', () => ({
@@ -140,6 +140,18 @@ describe('PlansLocator', function () {
expect(lookupKey).to.equal('student_annual_feb2026_eur')
})
it('should use the provided lookup key version', function (ctx) {
const planCode = 'collaborator'
const currency = 'eur'
const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
planCode,
currency,
undefined,
'jan2099'
)
expect(lookupKey).to.equal('standard_monthly_jan2099_eur')
})
it('should return null for unknown add-on codes', function (ctx) {
const billingCycleInterval = 'month'
const addOnCode = 'unknown_addon'
@@ -185,6 +197,47 @@ describe('PlansLocator', function () {
})
})
describe('getVersionFromStripeLookupKey', function () {
it('returns the version from a plan lookup key', function (ctx) {
expect(
ctx.PlansLocator.getVersionFromStripeLookupKey(
'standard_monthly_jan2099_eur'
)
).to.equal('jan2099')
})
it('returns the version from an add-on lookup key', function (ctx) {
expect(
ctx.PlansLocator.getVersionFromStripeLookupKey(
'assistant_annual_feb2026_gbp'
)
).to.equal('feb2026')
})
it('returns the version from a discounted lookup key', function (ctx) {
expect(
ctx.PlansLocator.getVersionFromStripeLookupKey(
'group_professional_educational_jan2099_usd_discount_20'
)
).to.equal('jan2099')
})
it('returns undefined for an unrecognised version', function (ctx) {
expect(
ctx.PlansLocator.getVersionFromStripeLookupKey(
'standard_monthly_jun2025_eur'
)
).to.be.undefined
})
it('returns undefined for missing input', function (ctx) {
expect(ctx.PlansLocator.getVersionFromStripeLookupKey(null)).to.be
.undefined
expect(ctx.PlansLocator.getVersionFromStripeLookupKey(undefined)).to.be
.undefined
})
})
describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () {
it('should return the plan type and period for "collaborator"', function (ctx) {
const { planType, period } =
@@ -161,6 +161,7 @@ describe('SubscriptionController', function () {
ctx.SplitTestV2Hander = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
ctx.Features = {
@@ -484,6 +485,25 @@ describe('SubscriptionController', function () {
expect(ctx.data.plans).to.deep.equal(ctx.plans)
})
// The prices quoted in the change plan modal have to come from the same
// price version the plan change will be charged at
it('should resolve the price version for the plan list by user, not by request', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'getPriceVersionForUser',
ctx.user._id
)
expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith(
'getPriceVersion',
sinon.match.any,
sinon.match.any
)
expect(
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash
).to.have.been.calledWithMatch(sinon.match.any, sinon.match.any, {
priceVersion: 'feb2026',
})
})
it('should load an empty list of groups with settings available', function (ctx) {
expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
})
@@ -108,6 +108,49 @@ describe('SubscriptionViewModelBuilder', function () {
ctx.Settings = {
institutionPlanCode: ctx.commonsPlanCode,
plans: [
{ planCode: 'student', name: 'Student', price_in_cents: 1000 },
{
planCode: 'student-annual',
name: 'Student Annual',
price_in_cents: 10000,
annual: true,
},
{ planCode: 'collaborator', name: 'Standard', price_in_cents: 2100 },
{
planCode: 'collaborator-annual',
name: 'Standard Annual',
price_in_cents: 21000,
annual: true,
},
{
planCode: 'professional',
name: 'Professional',
price_in_cents: 4200,
},
{
planCode: 'professional-annual',
name: 'Professional Annual',
price_in_cents: 42000,
annual: true,
},
],
localizedPlanPricing: {
USD: {
student: { monthly: 10, annual: 100 },
collaborator: { monthly: 21, annual: 210 },
professional: { monthly: 42, annual: 420 },
},
},
localizedPlanPricingByVersion: {
jan2099: {
USD: {
student: { monthly: 12 },
collaborator: { monthly: 25 },
professional: { monthly: 50 },
},
},
},
}
ctx.SubscriptionLocator = {
promises: {
@@ -705,6 +748,7 @@ describe('SubscriptionViewModelBuilder', function () {
nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
nextPaymentDueDate: 'February 20th, 2025',
currency: 'EUR',
planPrice: 13,
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
@@ -1079,4 +1123,131 @@ describe('SubscriptionViewModelBuilder', function () {
})
})
})
describe('buildPlansListForSubscriptionDash', function () {
it('quotes the default price version when no other version is given', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{ currency: 'USD', priceVersion: 'feb2026' }
)
const pricesByPlanCode = Object.fromEntries(
plans.map(plan => [plan.planCode, plan.listPrice])
)
assert.deepEqual(pricesByPlanCode, {
student: 10,
'student-annual': 100,
collaborator: 21,
'collaborator-annual': 210,
professional: 42,
'professional-annual': 420,
})
})
it('quotes the given price version with its overrides applied', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{ currency: 'USD', priceVersion: 'jan2099' }
)
const pricesByPlanCode = Object.fromEntries(
plans.map(plan => [plan.planCode, plan.listPrice])
)
assert.deepEqual(pricesByPlanCode, {
student: 12,
'student-annual': 100,
collaborator: 25,
'collaborator-annual': 210,
professional: 50,
'professional-annual': 420,
})
})
it("quotes the user's current plan at the subscription's actual price", function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{
currency: 'USD',
priceVersion: 'jan2099',
subscriptionPlanCode: 'collaborator',
subscriptionPlanPrice: 21,
}
)
const pricesByPlanCode = Object.fromEntries(
plans.map(plan => [plan.planCode, plan.listPrice])
)
assert.deepEqual(pricesByPlanCode, {
student: 12,
'student-annual': 100,
collaborator: 21,
'collaborator-annual': 210,
professional: 50,
'professional-annual': 420,
})
})
it('matches the current plan ignoring any trial suffix', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
true,
{
currency: 'USD',
priceVersion: 'jan2099',
subscriptionPlanCode: 'collaborator_free_trial_7_days',
subscriptionPlanPrice: 21,
}
)
const collaborator = plans.find(plan => plan.planCode === 'collaborator')
assert.equal(collaborator.listPrice, 21)
})
it('falls back to the versioned price when the subscription price is unknown', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{
currency: 'USD',
priceVersion: 'jan2099',
subscriptionPlanCode: 'collaborator',
}
)
const collaborator = plans.find(plan => plan.planCode === 'collaborator')
assert.equal(collaborator.listPrice, 25)
})
it('leaves the price unset for a currency with no pricing', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{ currency: 'XYZ', priceVersion: 'feb2026' }
)
assert.isTrue(plans.every(plan => plan.listPrice === undefined))
})
it('leaves the price unset when no currency or version is given', function (ctx) {
const { plans } =
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false
)
assert.isTrue(plans.every(plan => plan.listPrice === undefined))
})
it('does not write prices onto the shared plans in settings', function (ctx) {
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
undefined,
false,
{ currency: 'USD', priceVersion: 'jan2099' }
)
assert.isTrue(
ctx.Settings.plans.every(plan => plan.listPrice === undefined)
)
})
})
})