mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #36711 from overleaf/oa-prices
[web] Prices, their versions and helper functions GitOrigin-RevId: b42f16fe3b083a4872230f2e348b247650d08df8
This commit is contained in:
@@ -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,
|
||||
|
||||
+13
-7
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1232,7 +1232,8 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
const { plans } =
|
||||
ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
|
||||
undefined,
|
||||
false
|
||||
false,
|
||||
{}
|
||||
)
|
||||
assert.isTrue(plans.every(plan => plan.listPrice === undefined))
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>[]
|
||||
addOns?: AddOn[]
|
||||
features?: Features
|
||||
|
||||
Reference in New Issue
Block a user