From c2cd5d686cdd1db73776e065759fa2c3dab9054d Mon Sep 17 00:00:00 2001 From: Olzhas Askar Date: Thu, 20 Aug 2026 12:11:09 +0200 Subject: [PATCH] Merge pull request #36711 from overleaf/oa-prices [web] Prices, their versions and helper functions GitOrigin-RevId: b42f16fe3b083a4872230f2e348b247650d08df8 --- .../src/Features/Subscription/PlansHelper.mjs | 31 ---------------- .../Features/Subscription/PriceVersions.mjs | 35 +++++++++++++++++++ .../SubscriptionViewModelBuilder.mjs | 24 ++++++++----- ...Helper.test.mjs => PriceVersions.test.mjs} | 20 +++++++---- .../SubscriptionViewModelBuilder.test.mjs | 3 +- services/web/types/subscription/plan.ts | 3 ++ 6 files changed, 69 insertions(+), 47 deletions(-) rename services/web/test/unit/src/Subscription/{PlansHelper.test.mjs => PriceVersions.test.mjs} (74%) diff --git a/services/web/app/src/Features/Subscription/PlansHelper.mjs b/services/web/app/src/Features/Subscription/PlansHelper.mjs index de2b822c49..fa310a9316 100644 --- a/services/web/app/src/Features/Subscription/PlansHelper.mjs +++ b/services/web/app/src/Features/Subscription/PlansHelper.mjs @@ -15,38 +15,7 @@ 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 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 annualSavings(monthly, annual) { - return 1 - annual / (monthly * 12) -} - export default { isProfessionalPlan, isProfessionalGroupPlan, - roundedTwelfth, - annualSavings, } diff --git a/services/web/app/src/Features/Subscription/PriceVersions.mjs b/services/web/app/src/Features/Subscription/PriceVersions.mjs index b53e37e201..c38f36fdcd 100644 --- a/services/web/app/src/Features/Subscription/PriceVersions.mjs +++ b/services/web/app/src/Features/Subscription/PriceVersions.mjs @@ -17,9 +17,44 @@ export const DEFAULT_PRICE_VERSION = 'feb2026' /** @type {StripeLookupKeyVersion[]} */ export const PRICE_VERSIONS = [ DEFAULT_PRICE_VERSION, + // These additional versions include the new price ...(Settings.additionalPriceVersions ?? []), ] +// How coarsely the per-month price of an annually billed plan is rounded for +// display, unless a price version overrides it. +const DEFAULT_ROUNDING_INCREMENT = 0.05 +const NEW_PRICES_ROUNDING_INCREMENT = 0.25 + +/** + * 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 increment for a + * tidy display value. + * + * @param {StripeLookupKeyVersion} version + * @returns {(annual: number) => number} + */ +export function getRoundedTwelfth(version) { + const increment = + version === DEFAULT_PRICE_VERSION + ? DEFAULT_ROUNDING_INCREMENT + : NEW_PRICES_ROUNDING_INCREMENT + return annual => + 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 annualSavings(monthly, annual) { + return 1 - annual / (monthly * 12) +} + /** @type {Map} */ const localizedPlanPricingCache = new Map() diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs index 57aadd99de..437726cc50 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs @@ -2,7 +2,7 @@ import Settings from '@overleaf/settings' import PlansLocator from './PlansLocator.mjs' -import { getLocalizedPlanPricing } from './PriceVersions.mjs' +import { getLocalizedPlanPricing, getRoundedTwelfth } from './PriceVersions.mjs' import { isStandaloneAiAddOnPlanCode } from './AiHelper.mjs' import PaymentProviderEntities from './PaymentProviderEntities.mjs' import SubscriptionLocator from './SubscriptionLocator.mjs' @@ -601,7 +601,7 @@ function buildGroupSubscriptionForView(groupSubscription) { /** * @param {any} currentPlan * @param {boolean} isInTrial - * @param {object} [options] + * @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 @@ -613,25 +613,33 @@ function buildGroupSubscriptionForView(groupSubscription) { function buildPlansListForSubscriptionDash( currentPlan, isInTrial, - { currency, priceVersion, subscriptionPlanCode, subscriptionPlanPrice } = {} + { currency, priceVersion, subscriptionPlanCode, subscriptionPlanPrice } ) { const { allPlans, planCodesChangingAtTermEnd } = buildPlansList( currentPlan, isInTrial ) const currentPlanCode = subscriptionPlanCode?.split('_')[0] + const roundedTwelfth = getRoundedTwelfth(priceVersion) 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: + .map(plan => { + const listPrice = plan.planCode === currentPlanCode && subscriptionPlanPrice != null ? subscriptionPlanPrice : currency && priceVersion ? _getListPriceForPlanChange(plan.planCode, currency, priceVersion) - : undefined, - })) + : undefined + const monthlyEquivalentListPrice = + plan.annual && listPrice ? roundedTwelfth(listPrice) : undefined + + return { + ...plan, + listPrice, + monthlyEquivalentListPrice, + } + }) return { plans, planCodesChangingAtTermEnd, diff --git a/services/web/test/unit/src/Subscription/PlansHelper.test.mjs b/services/web/test/unit/src/Subscription/PriceVersions.test.mjs similarity index 74% rename from services/web/test/unit/src/Subscription/PlansHelper.test.mjs rename to services/web/test/unit/src/Subscription/PriceVersions.test.mjs index 26b309ecd7..5f2dcf864e 100644 --- a/services/web/test/unit/src/Subscription/PlansHelper.test.mjs +++ b/services/web/test/unit/src/Subscription/PriceVersions.test.mjs @@ -1,17 +1,19 @@ import { describe, expect, it } from 'vitest' import { annualSavings, - roundedTwelfth, -} from '../../../../app/src/Features/Subscription/PlansHelper.mjs' + getRoundedTwelfth, +} from '../../../../app/src/Features/Subscription/PriceVersions.mjs' describe('PlansHelper', function () { describe('roundedTwelfth', function () { it('divides by twelve when the result is already a multiple of 0.05', function () { + const roundedTwelfth = getRoundedTwelfth('feb2026') expect(roundedTwelfth(120)).to.equal(10) expect(roundedTwelfth(180)).to.equal(15) }) it('rounds up to the nearest 0.05 for display', function () { + const roundedTwelfth = getRoundedTwelfth('feb2026') // 263 / 12 = 21.9166… -> 21.95 expect(roundedTwelfth(263)).to.equal(21.95) // 505 / 12 = 42.0833… -> 42.1 @@ -19,12 +21,13 @@ describe('PlansHelper', function () { }) it('rounds up to the nearest 0.25 when given a 0.25 increment', function () { + const roundedTwelfth = getRoundedTwelfth('aug2026') // 159 / 12 = 13.25 -> already a multiple of 0.25 - expect(roundedTwelfth(159, 0.25)).to.equal(13.25) + expect(roundedTwelfth(159)).to.equal(13.25) // 76 / 12 = 6.3333… -> 6.5 - expect(roundedTwelfth(76, 0.25)).to.equal(6.5) + expect(roundedTwelfth(76)).to.equal(6.5) // 319 / 12 = 26.5833… -> 26.75 - expect(roundedTwelfth(319, 0.25)).to.equal(26.75) + expect(roundedTwelfth(319)).to.equal(26.75) }) }) @@ -37,13 +40,16 @@ describe('PlansHelper', function () { }) it('ignores the rounding applied to the displayed per-month price', function () { + let roundedTwelfth = getRoundedTwelfth('feb2026') // 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 + 1 - roundedTwelfth(100) / 10 ) + + roundedTwelfth = getRoundedTwelfth('aug2026') expect(annualSavings(10, 100)).to.be.greaterThan( - 1 - roundedTwelfth(100, 0.25) / 10 + 1 - roundedTwelfth(100) / 10 ) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs index 43d623482e..1c5f981cbe 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs @@ -1232,7 +1232,8 @@ describe('SubscriptionViewModelBuilder', function () { const { plans } = ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash( undefined, - false + false, + {} ) assert.isTrue(plans.every(plan => plan.listPrice === undefined)) }) diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 82b16cc541..12358e88aa 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -57,6 +57,9 @@ export type Plan = { // list price excluding tax, in the subscription's currency, resolved from the // user's assigned price version. Only set for the change plan modal. listPrice?: number + // per-month equivalent of the annual list price, rounded per the price + // version's rounding rules. Only set for annual plans in the change plan modal. + monthlyEquivalentListPrice?: number featureDescription?: Record[] addOns?: AddOn[] features?: Features