mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -21,6 +21,7 @@ import {
|
||||
import { Institution } from '../../../../../types/institution'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import {
|
||||
formatPriceForDisplayData,
|
||||
loadDisplayPriceWithTaxPromise,
|
||||
loadGroupDisplayPriceWithTaxForRecurlyPromise,
|
||||
loadGroupDisplayPriceWithTaxForStripePromise,
|
||||
@@ -173,15 +174,25 @@ export function SubscriptionDashboardProvider({
|
||||
personalSubscription?.payment
|
||||
) {
|
||||
const { currency, taxRate } = personalSubscription.payment
|
||||
// Only Stripe subscriptions can be derived from the price version. Recurly only has one version of each price.
|
||||
const isStripe = Boolean(personalSubscription.service?.includes('stripe'))
|
||||
const fetchPlansDisplayPrices = async () => {
|
||||
for (const plan of plansWithoutDisplayPrice) {
|
||||
try {
|
||||
const priceData = await loadDisplayPriceWithTaxPromise(
|
||||
plan.planCode,
|
||||
currency,
|
||||
taxRate,
|
||||
i18n.language
|
||||
)
|
||||
const priceData =
|
||||
isStripe && plan.listPrice !== undefined
|
||||
? formatPriceForDisplayData(
|
||||
plan.listPrice,
|
||||
taxRate,
|
||||
currency,
|
||||
i18n.language
|
||||
)
|
||||
: await loadDisplayPriceWithTaxPromise(
|
||||
plan.planCode,
|
||||
currency,
|
||||
taxRate,
|
||||
i18n.language
|
||||
)
|
||||
if (priceData?.totalAsNumber !== undefined) {
|
||||
plan.displayPrice = formatCurrency(
|
||||
priceData.totalAsNumber,
|
||||
|
||||
@@ -28,12 +28,12 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
|
||||
}
|
||||
|
||||
export function formatPriceForDisplayData(
|
||||
price: string,
|
||||
price: string | number,
|
||||
taxRate: number,
|
||||
currencyCode: CurrencyCode,
|
||||
locale: string
|
||||
): PriceForDisplayData {
|
||||
const totalPriceExTax = parseFloat(price)
|
||||
const totalPriceExTax = typeof price === 'number' ? price : parseFloat(price)
|
||||
let taxAmount = totalPriceExTax * taxRate
|
||||
if (isNaN(taxAmount)) {
|
||||
taxAmount = 0
|
||||
|
||||
@@ -169,7 +169,8 @@
|
||||
|
||||
&[data-ol-student-mode='true'] {
|
||||
.plans-new-group-member-picker,
|
||||
.plans-educational-discount {
|
||||
.plans-educational-discount,
|
||||
.hide-for-plan-type-student {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -2780,6 +2780,7 @@
|
||||
"this_will_remove_primary_email": "Note that this will also remove the email address <b>__email__</b> from the account because it’s an unconfirmed email. If you want to keep it, please confirm it first.",
|
||||
"this_will_reset_in": "This will reset in __time__.",
|
||||
"three_free_collab": "Three free collaborators",
|
||||
"three_plus_months_free": "3+ months free",
|
||||
"time_and": "and",
|
||||
"time_hour": "__count__ hour",
|
||||
"time_hour_plural": "__count__ hours",
|
||||
|
||||
+31
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ type PaymentProviderRecord = {
|
||||
nextPaymentDueAt: string
|
||||
nextPaymentDueDate: string
|
||||
currency: CurrencyCode
|
||||
planPrice: number
|
||||
state?: SubscriptionState
|
||||
trialEndsAtFormatted: Nullable<string>
|
||||
trialEndsAt: Nullable<string>
|
||||
|
||||
@@ -53,6 +53,9 @@ export type PendingPaymentProviderPlan = {
|
||||
export type Plan = {
|
||||
annual?: boolean
|
||||
displayPrice?: string
|
||||
// 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
|
||||
featureDescription?: Record<string, unknown>[]
|
||||
addOns?: AddOn[]
|
||||
features?: Features
|
||||
@@ -109,8 +112,10 @@ export type StripeBaseLookupKey =
|
||||
| 'group_standard_educational'
|
||||
| 'group_professional_educational'
|
||||
|
||||
// Keep in sync with LATEST_STRIPE_LOOKUP_KEY_VERSION in PlansLocator.mjs
|
||||
export type StripeLookupKeyVersion = 'feb2026'
|
||||
// A version of prices we start subscriptions at: PlansLocator's
|
||||
// DEFAULT_STRIPE_LOOKUP_KEY_VERSION, or any additional versions
|
||||
// defined in settings
|
||||
export type StripeLookupKeyVersion = 'feb2026' | (string & {})
|
||||
|
||||
export type StripeLookupKey =
|
||||
`${StripeBaseLookupKey}_${StripeLookupKeyVersion}_${StripeCurrencyCode}`
|
||||
|
||||
Reference in New Issue
Block a user