Merge pull request #36711 from overleaf/oa-prices

[web] Prices, their versions and helper functions

GitOrigin-RevId: b42f16fe3b083a4872230f2e348b247650d08df8
This commit is contained in:
Olzhas Askar
2026-09-17 12:01:51 +00:00
committed by Copybot
parent a301363ece
commit c2cd5d686c
6 changed files with 69 additions and 47 deletions
@@ -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,
}
@@ -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<StripeLookupKeyVersion, any>} */
const localizedPlanPricingCache = new Map()
@@ -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,