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
@@ -3,7 +3,7 @@
/**
* @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription'
* @import { CurrencyCode, StripeCurrencyCode } from '../../../../types/subscription/currency'
* @import { AddOn } from '../../../../types/subscription/plan'
* @import { AddOn, StripeLookupKeyVersion } from '../../../../types/subscription/plan'
*/
/**
@@ -156,9 +156,15 @@ export class PaymentProviderSubscription {
* @param {string} planCode - the new plan code
* @param {number} [quantity] - the quantity of the plan
* @param {boolean} [shouldChangeAtTermEnd] - whether the change should be applied at the end of the term
* @param {StripeLookupKeyVersion} [lookupKeyVersion] - the price version to charge the new plan at
* @return {PaymentProviderSubscriptionChangeRequest}
*/
getRequestForPlanChange(planCode, quantity, shouldChangeAtTermEnd) {
getRequestForPlanChange(
planCode,
quantity,
shouldChangeAtTermEnd,
lookupKeyVersion
) {
const timeframe = shouldChangeAtTermEnd ? 'term_end' : 'now'
const baseAddOns = this.#getBaseAddOnsForChangeRequest(timeframe)
@@ -198,6 +204,7 @@ export class PaymentProviderSubscription {
timeframe,
planCode,
addOnUpdates,
lookupKeyVersion,
})
return changeRequest
@@ -538,6 +545,9 @@ export class PaymentProviderSubscriptionChangeRequest {
* @param {"now" | "term_end"} props.timeframe
* @param {string} [props.planCode]
* @param {PaymentProviderSubscriptionAddOnUpdate[]} [props.addOnUpdates]
* @param {StripeLookupKeyVersion} [props.lookupKeyVersion] - price version to
* use for the resulting subscription items. When omitted, the version of the
* subscription's current plan price is preserved.
*/
constructor(props) {
if (props.planCode == null && props.addOnUpdates == null) {
@@ -549,6 +559,7 @@ export class PaymentProviderSubscriptionChangeRequest {
this.timeframe = props.timeframe
this.planCode = props.planCode ?? null
this.addOnUpdates = props.addOnUpdates ?? null
this.lookupKeyVersion = props.lookupKeyVersion ?? null
}
}
@@ -15,20 +15,38 @@ export function isProfessionalGroupPlan(subscription) {
return subscription.groupPlan && isProfessional
}
// How coarsely the per-month price of an annually billed plan is rounded for
// display, unless a price version overrides it.
export const DEFAULT_ROUNDING_INCREMENT = 0.05
/**
* The per-month price shown when a plan or add-on is billed annually: the
* annual price divided by twelve, rounded up to the nearest 0.05 for a tidy
* display value.
* annual price divided by twelve, rounded up to the nearest increment for a
* tidy display value.
*
* @param {number} annual
* @param {number} [increment]
* @returns {number}
*/
export function roundedTwelfth(annual, increment = DEFAULT_ROUNDING_INCREMENT) {
return Math.round(Math.ceil(annual / 12 / increment) * increment * 100) / 100
}
/**
* The saving from billing annually rather than monthly, as a fraction of what a
* year of monthly billing would cost.
*
* @param {number} monthly
* @param {number} annual
* @returns {number}
*/
export function roundedTwelfth(annual) {
return Math.round(Math.ceil(annual / 12 / 0.05) * 5) / 100
export function annualSavings(monthly, annual) {
return 1 - annual / (monthly * 12)
}
export default {
isProfessionalPlan,
isProfessionalGroupPlan,
roundedTwelfth,
annualSavings,
}
@@ -4,10 +4,13 @@ import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { DEFAULT_PRICE_VERSION, PRICE_VERSIONS } from './PriceVersions.mjs'
/**
* @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode
* @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey
* @typedef {import('../../../../types/subscription/plan').StripeBaseLookupKey} StripeBaseLookupKey
* @typedef {import('../../../../types/subscription/plan').StripeLookupKeyVersion} StripeLookupKeyVersion
* @typedef {import('../../../../types/subscription/plan').Plan} Plan
* @typedef {import('../../../../types/subscription/currency').StripeCurrencyCode} StripeCurrencyCode
* @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval
@@ -57,20 +60,46 @@ const recurlyCodeToStripeBaseLookupKey = {
'assistant-annual': 'assistant_annual',
}
// Keep in sync with StripeLookupKeyVersion in types/subscription/plan.ts
const LATEST_STRIPE_LOOKUP_KEY_VERSION = 'feb2026'
const DEFAULT_STRIPE_LOOKUP_KEY_VERSION = DEFAULT_PRICE_VERSION
const STRIPE_LOOKUP_KEY_VERSIONS = PRICE_VERSIONS
/**
* Extract the version from a Stripe lookup key, so that changes to an existing
* subscription can be priced at the version it was created with.
*
* Only currently served versions are recognised: a subscription still priced at
* a retired version returns undefined, so we fall back to the default.
*
* @param {string|null|undefined} lookupKey
* @returns {StripeLookupKeyVersion|undefined} undefined if the key has no
* currently served version
*/
function getVersionFromStripeLookupKey(lookupKey) {
if (!lookupKey) {
return undefined
}
return STRIPE_LOOKUP_KEY_VERSIONS.find(version =>
lookupKey.includes(`_${version}_`)
)
}
/**
* Build the Stripe lookup key, will be in this format:
* `${productCode}_${billingInterval}_${latestVersion}_${currency}`
* `${productCode}_${billingInterval}_${version}_${currency}`
* (for example: 'assistant_annual_jun2025_clp')
*
* @param {RecurlyPlanCode} recurlyCode
* @param {StripeCurrencyCode} currency
* @param {BillingCycleInterval} [billingCycleInterval] -- needed for handling 'assistant' add-on
* @param {StripeLookupKeyVersion} [lookupKeyVersion]
* @returns {StripeLookupKey|null}
*/
function buildStripeLookupKey(recurlyCode, currency, billingCycleInterval) {
function buildStripeLookupKey(
recurlyCode,
currency,
billingCycleInterval,
lookupKeyVersion = DEFAULT_STRIPE_LOOKUP_KEY_VERSION
) {
let stripeBaseLookupKey = recurlyCodeToStripeBaseLookupKey[recurlyCode]
// Recurly always uses 'assistant' as the code regardless of the subscription duration
@@ -87,7 +116,7 @@ function buildStripeLookupKey(recurlyCode, currency, billingCycleInterval) {
return null
}
return `${stripeBaseLookupKey}_${LATEST_STRIPE_LOOKUP_KEY_VERSION}_${currency}`
return `${stripeBaseLookupKey}_${lookupKeyVersion}_${currency}`
}
/**
@@ -192,5 +221,7 @@ export default {
getPlanTypeAndPeriodFromRecurlyPlanCode,
isGroupPlanCode,
convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
LATEST_STRIPE_LOOKUP_KEY_VERSION,
getVersionFromStripeLookupKey,
DEFAULT_STRIPE_LOOKUP_KEY_VERSION,
STRIPE_LOOKUP_KEY_VERSIONS,
}
@@ -0,0 +1,56 @@
// @ts-check
import Settings from '@overleaf/settings'
/**
* @typedef {import('../../../../types/subscription/plan').StripeLookupKeyVersion} StripeLookupKeyVersion
*/
// The version new subscriptions are priced at, unless overridden by the getPriceVersion/getPriceVersionForUser hooks.
/** @type {StripeLookupKeyVersion} */
export const DEFAULT_PRICE_VERSION = 'feb2026'
// Every version we currently serve prices at: the default, plus any additional defined
// versions. Only prices NEW subscriptions
// can start at, not retired prices that existing subscriptions might still be
// on.
/** @type {StripeLookupKeyVersion[]} */
export const PRICE_VERSIONS = [
DEFAULT_PRICE_VERSION,
...(Settings.additionalPriceVersions ?? []),
]
/** @type {Map<StripeLookupKeyVersion, any>} */
const localizedPlanPricingCache = new Map()
/**
* The localized plan pricing table for a price version:
* Settings.localizedPlanPricing with the version's overrides from
* Settings.localizedPlanPricingByVersion applied on top.
*
* @param {StripeLookupKeyVersion} version
* @returns {any}
*/
export function getLocalizedPlanPricing(version) {
const overrides = Settings.localizedPlanPricingByVersion?.[version]
if (!overrides) {
return Settings.localizedPlanPricing
}
let pricing = localizedPlanPricingCache.get(version)
if (!pricing) {
pricing = {}
for (const [currency, plans] of Object.entries(
Settings.localizedPlanPricing
)) {
pricing[currency] = {}
for (const [plan, prices] of Object.entries(plans)) {
pricing[currency][plan] = {
...prices,
...overrides[currency]?.[plan],
}
}
}
localizedPlanPricingCache.set(version, pricing)
}
return pricing
}
@@ -24,6 +24,7 @@ import async from 'async'
import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs'
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
import PlansLocator from './PlansLocator.mjs'
import { DEFAULT_PRICE_VERSION } from './PriceVersions.mjs'
import { User } from '../../models/User.mjs'
import UserGetter from '../User/UserGetter.mjs'
import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.mjs'
@@ -198,10 +199,22 @@ async function userSubscriptionPage(req, res) {
const isInTrial = SubscriptionHelper.isInTrial(
personalSubscription?.payment?.trialEndsAt
)
// The change plan modal must quote prices from the same price version that the
// plan change itself will be charged at, except for the user's current plan, which is shown at their current price
const priceVersion =
(
await Modules.promises.hooks.fire('getPriceVersionForUser', user._id)
)?.[0] ?? DEFAULT_PRICE_VERSION
const plansData =
SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
personalSubscription?.plan,
isInTrial
isInTrial,
{
currency: personalSubscription?.payment?.currency,
priceVersion,
subscriptionPlanCode: personalSubscription?.planCode,
subscriptionPlanPrice: personalSubscription?.payment?.planPrice,
}
)
const host = req.headers.host
@@ -2,6 +2,7 @@
import Settings from '@overleaf/settings'
import PlansLocator from './PlansLocator.mjs'
import { getLocalizedPlanPricing } from './PriceVersions.mjs'
import { isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
import PaymentProviderEntities from './PaymentProviderEntities.mjs'
import SubscriptionFormatters from './SubscriptionFormatters.mjs'
@@ -308,6 +309,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
paymentRecord.subscription.periodEnd
),
currency: paymentRecord.subscription.currency,
planPrice: paymentRecord.subscription.planPrice,
state: paymentRecord.subscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
paymentRecord.subscription.trialPeriodEnd
@@ -544,6 +546,22 @@ const CHANGE_PLAN_MODAL_PLAN_CODES = [
'professional-annual',
]
/**
* The list price of a "Change plan" modal plan, in the given currency and at the
* given price version.
*
* @param {string} planCode
* @param {string} currency
* @param {import('../../../../types/subscription/plan').StripeLookupKeyVersion} priceVersion
* @returns {number|undefined} the price excluding tax
*/
function _getListPriceForPlanChange(planCode, currency, priceVersion) {
const isAnnual = planCode.endsWith('-annual')
const pricingKey = isAnnual ? planCode.replace(/-annual$/, '') : planCode
const pricing = getLocalizedPlanPricing(priceVersion)
return pricing[currency]?.[pricingKey]?.[isAnnual ? 'annual' : 'monthly']
}
function _isPlanEqualOrBetter(planA, planB) {
return FeaturesHelper.isFeatureSetBetter(
planA?.features || {},
@@ -589,15 +607,42 @@ function buildGroupSubscriptionForView(groupSubscription) {
}
}
function buildPlansListForSubscriptionDash(currentPlan, isInTrial) {
/**
* @param {any} currentPlan
* @param {boolean} isInTrial
* @param {object} [options]
* @param {string} [options.currency] - the subscription's currency
* @param {import('../../../../types/subscription/plan').StripeLookupKeyVersion} [options.priceVersion]
* @param {string} [options.subscriptionPlanCode] - the plan code of the user's
* current subscription
* @param {number} [options.subscriptionPlanPrice] - the plan price the user's
* subscription is actually charged at, which may differ from the price at
* `priceVersion` (e.g. a subscription predating the version's split test)
*/
function buildPlansListForSubscriptionDash(
currentPlan,
isInTrial,
{ currency, priceVersion, subscriptionPlanCode, subscriptionPlanPrice } = {}
) {
const { allPlans, planCodesChangingAtTermEnd } = buildPlansList(
currentPlan,
isInTrial
)
const currentPlanCode = subscriptionPlanCode?.split('_')[0]
const plans = CHANGE_PLAN_MODAL_PLAN_CODES.map(code => allPlans[code])
.filter(Boolean)
// shallow copy: these are the shared Settings.plans objects
.map(plan => ({
...plan,
listPrice:
plan.planCode === currentPlanCode && subscriptionPlanPrice != null
? subscriptionPlanPrice
: currency && priceVersion
? _getListPriceForPlanChange(plan.planCode, currency, priceVersion)
: undefined,
}))
return {
plans: CHANGE_PLAN_MODAL_PLAN_CODES.map(code => allPlans[code]).filter(
Boolean
),
plans,
planCodesChangingAtTermEnd,
}
}