diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 7f2949d266..5ec4f6d7e9 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -21,6 +21,8 @@ const SubscriptionHelper = require('./SubscriptionHelper') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') const async = require('async') +const { formatCurrencyLocalized } = require('../../util/currency') +const SubscriptionFormatters = require('./SubscriptionFormatters') const groupPlanModalOptions = Settings.groupPlanModalOptions const validGroupPlanModalOptions = { @@ -31,6 +33,8 @@ const validGroupPlanModalOptions = { } async function plansPage(req, res) { + const language = req.i18n.language || 'en' + const plans = SubscriptionViewModelBuilder.buildPlansList() let currency = null const queryCurrency = req.query.currency?.toUpperCase() @@ -81,6 +85,16 @@ async function plansPage(req, res) { geoPricingLATAMTestVariant === 'latam' && ['MX', 'CO', 'CL', 'PE'].includes(countryCode) + const localCcyAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'local-ccy-format' + ) + const formatCurrency = + localCcyAssignment.variant === 'enabled' + ? formatCurrencyLocalized + : SubscriptionHelper.formatCurrencyDefault + res.render('subscriptions/plans', { title: 'plans_and_pricing', currentView, @@ -88,6 +102,8 @@ async function plansPage(req, res) { itm_content: req.query?.itm_content, itm_referrer: req.query?.itm_referrer, itm_campaign: 'plans', + language, + formatCurrency, recommendedCurrency: currency, planFeatures, plansConfig, @@ -95,7 +111,11 @@ async function plansPage(req, res) { groupPlanModalOptions, groupPlanModalDefaults, initialLocalizedGroupPrice: - SubscriptionHelper.generateInitialLocalizedGroupPrice(currency), + SubscriptionHelper.generateInitialLocalizedGroupPrice( + currency ?? 'USD', + language, + formatCurrency + ), showInrGeoBanner: countryCode === 'IN', showBrlGeoBanner: countryCode === 'BR', showLATAMBanner, @@ -119,9 +139,20 @@ function formatGroupPlansDataForDash() { */ async function userSubscriptionPage(req, res) { const user = SessionManager.getSessionUser(req.session) + + const localCcyAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'local-ccy-format' + ) + const results = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - user + user, + req.i18n.language, + localCcyAssignment.variant === 'enabled' + ? SubscriptionFormatters.formatPriceLocalized + : SubscriptionFormatters.formatPriceDefault ) const { personalSubscription, @@ -227,6 +258,12 @@ async function interstitialPaymentPage(req, res) { geoPricingLATAMTestVariant === 'latam' && ['MX', 'CO', 'CL', 'PE'].includes(countryCode) + const localCcyAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'local-ccy-format' + ) + res.render('subscriptions/interstitial-payment', { title: 'subscribe', itm_content: req.query?.itm_content, @@ -235,6 +272,11 @@ async function interstitialPaymentPage(req, res) { recommendedCurrency, interstitialPaymentConfig, showSkipLink, + formatCurrency: + localCcyAssignment.variant === 'enabled' + ? formatCurrencyLocalized + : SubscriptionHelper.formatCurrencyDefault, + showCurrencyAndPaymentMethods: localCcyAssignment.variant === 'enabled', showInrGeoBanner: countryCode === 'IN', showBrlGeoBanner: countryCode === 'BR', showLATAMBanner, @@ -251,9 +293,18 @@ async function interstitialPaymentPage(req, res) { */ async function successfulSubscription(req, res) { const user = SessionManager.getSessionUser(req.session) + const localCcyAssignment = await SplitTestHandler.promises.getAssignment( + req, + res, + 'local-ccy-format' + ) const { personalSubscription } = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - user + user, + req.i18n.language, + localCcyAssignment.variant === 'enabled' + ? SubscriptionFormatters.formatPriceLocalized + : SubscriptionFormatters.formatPriceDefault ) const postCheckoutRedirect = req.session?.postCheckoutRedirect diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 249b265487..adf87f645f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js @@ -1,4 +1,5 @@ const dateformat = require('dateformat') +const { formatCurrencyLocalized } = require('../../util/currency') const currencySymbols = { EUR: '€', @@ -20,38 +21,62 @@ const currencySymbols = { PEN: 'S/', } -module.exports = { - formatPrice(priceInCents, currency) { - if (!currency) { - currency = 'USD' - } else if (currency === 'CLP') { - // CLP doesn't have minor units, recurly stores the whole major unit without cents - return priceInCents.toLocaleString('es-CL', { - style: 'currency', - currency, - minimumFractionDigits: 0, - }) - } - let string = String(Math.round(priceInCents)) - if (string.length === 2) { - string = `0${string}` - } - if (string.length === 1) { - string = `00${string}` - } - if (string.length === 0) { - string = '000' - } - const cents = string.slice(-2) - const dollars = string.slice(0, -2) - const symbol = currencySymbols[currency] - return `${symbol}${dollars}.${cents}` - }, - - formatDate(date) { - if (!date) { - return null - } - return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true) - }, +function formatPriceDefault(priceInCents, currency) { + if (!currency) { + currency = 'USD' + } else if (currency === 'CLP') { + // CLP doesn't have minor units, recurly stores the whole major unit without cents + return priceInCents.toLocaleString('es-CL', { + style: 'currency', + currency, + minimumFractionDigits: 0, + }) + } + let string = String(Math.round(priceInCents)) + if (string.length === 2) { + string = `0${string}` + } + if (string.length === 1) { + string = `00${string}` + } + if (string.length === 0) { + string = '000' + } + const cents = string.slice(-2) + const dollars = string.slice(0, -2) + const symbol = currencySymbols[currency] + return `${symbol}${dollars}.${cents}` +} + +/** + * @typedef {import('@/shared/utils/currency').CurrencyCode} CurrencyCode + */ + +/** + * @param {number} priceInCents - price in the smallest currency unit (e.g. dollar cents, CLP units, ...) + * @param {CurrencyCode?} currency - currency code (default to USD) + * @param {string} [locale] - locale string + * @returns {string} - formatted price + */ +function formatPriceLocalized(priceInCents, currency = 'USD', locale) { + const isNoCentsCurrency = ['CLP', 'JPY', 'KRW', 'VND'].includes(currency) + + const priceInCurrencyUnit = isNoCentsCurrency + ? priceInCents + : priceInCents / 100 + + return formatCurrencyLocalized(priceInCurrencyUnit, currency, locale) +} + +function formatDate(date) { + if (!date) { + return null + } + return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true) +} + +module.exports = { + formatPriceDefault, + formatPriceLocalized, + formatDate, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index 72dcfe6f49..e47fadd795 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -9,10 +9,22 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) { return oldPlan.price_in_cents > newPlan.price_in_cents } -function generateInitialLocalizedGroupPrice(recommendedCurrency) { +/** + * @typedef {import('../../../../frontend/js/shared/utils/currency').CurrencyCode} CurrencyCode + */ + +/** + * @param {CurrencyCode} recommendedCurrency + * @param {string} locale + * @param {(amount: number, currency: CurrencyCode, locale: string, stripIfInteger: boolean) => string} formatCurrency + * @returns {{ price: { collaborator: string, professional: string }, pricePerUser: { collaborator: string, professional: string } }} - localized group price + */ +function generateInitialLocalizedGroupPrice( + recommendedCurrency, + locale, + formatCurrency +) { const INITIAL_LICENSE_SIZE = 2 - const currencySymbols = Settings.groupPlanModalOptions.currencySymbols - const recommendedCurrencySymbol = currencySymbols[recommendedCurrency] // the price is in cents, so divide by 100 to get the value const collaboratorPrice = @@ -26,48 +38,44 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency) { ].price_in_cents / 100 const professionalPricePerUser = professionalPrice / INITIAL_LICENSE_SIZE + /** + * @param {number} price + * @returns {string} + */ + const formatPrice = price => + formatCurrency(price, recommendedCurrency, locale, true) + + return { + price: { + collaborator: formatPrice(collaboratorPrice), + professional: formatPrice(professionalPrice), + }, + pricePerUser: { + collaborator: formatPrice(collaboratorPricePerUser), + professional: formatPrice(professionalPricePerUser), + }, + } +} + +function formatCurrencyDefault(amount, recommendedCurrency) { + const currencySymbols = Settings.groupPlanModalOptions.currencySymbols + const recommendedCurrencySymbol = currencySymbols[recommendedCurrency] + switch (recommendedCurrency) { case 'CHF': { - return { - price: { - collaborator: `${recommendedCurrencySymbol} ${collaboratorPrice}`, - professional: `${recommendedCurrencySymbol} ${professionalPrice}`, - }, - pricePerUser: { - collaborator: `${recommendedCurrencySymbol} ${collaboratorPricePerUser}`, - professional: `${recommendedCurrencySymbol} ${professionalPricePerUser}`, - }, - } + return `${recommendedCurrencySymbol} ${amount}` } case 'DKK': case 'NOK': case 'SEK': - return { - price: { - collaborator: `${collaboratorPrice} ${recommendedCurrencySymbol}`, - professional: `${professionalPrice} ${recommendedCurrencySymbol}`, - }, - pricePerUser: { - collaborator: `${collaboratorPricePerUser} ${recommendedCurrencySymbol}`, - professional: `${professionalPricePerUser} ${recommendedCurrencySymbol}`, - }, - } - default: { - return { - price: { - collaborator: `${recommendedCurrencySymbol}${collaboratorPrice}`, - professional: `${recommendedCurrencySymbol}${professionalPrice}`, - }, - pricePerUser: { - collaborator: `${recommendedCurrencySymbol}${collaboratorPricePerUser}`, - professional: `${recommendedCurrencySymbol}${professionalPricePerUser}`, - }, - } - } + return `${amount} ${recommendedCurrencySymbol}` + default: + return `${recommendedCurrencySymbol}${amount}` } } module.exports = { + formatCurrencyDefault, shouldPlanChangeAtTermEnd, generateInitialLocalizedGroupPrice, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 081d4072c8..f05023c181 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -66,7 +66,11 @@ async function getRedirectToHostedPage(userId, pageType) { ].join('') } -async function buildUsersSubscriptionViewModel(user) { +async function buildUsersSubscriptionViewModel( + user, + locale = 'en', + formatPrice = SubscriptionFormatters.formatPriceDefault +) { let { personalSubscription, memberGroupSubscriptions, @@ -312,19 +316,19 @@ async function buildUsersSubscriptionViewModel(user) { const pendingSubscriptionTax = personalSubscription.recurly.taxRate * recurlySubscription.pending_subscription.unit_amount_in_cents - personalSubscription.recurly.displayPrice = - SubscriptionFormatters.formatPrice( - recurlySubscription.pending_subscription.unit_amount_in_cents + - pendingAddOnPrice + - pendingAddOnTax + - pendingSubscriptionTax, - recurlySubscription.currency - ) - personalSubscription.recurly.currentPlanDisplayPrice = - SubscriptionFormatters.formatPrice( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, - recurlySubscription.currency - ) + personalSubscription.recurly.displayPrice = formatPrice( + recurlySubscription.pending_subscription.unit_amount_in_cents + + pendingAddOnPrice + + pendingAddOnTax + + pendingSubscriptionTax, + recurlySubscription.currency, + locale + ) + personalSubscription.recurly.currentPlanDisplayPrice = formatPrice( + recurlySubscription.unit_amount_in_cents + addOnPrice + tax, + recurlySubscription.currency, + locale + ) const pendingTotalLicenses = (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses personalSubscription.recurly.pendingAdditionalLicenses = @@ -332,11 +336,11 @@ async function buildUsersSubscriptionViewModel(user) { personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses personalSubscription.pendingPlan = pendingPlan } else { - personalSubscription.recurly.displayPrice = - SubscriptionFormatters.formatPrice( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, - recurlySubscription.currency - ) + personalSubscription.recurly.displayPrice = formatPrice( + recurlySubscription.unit_amount_in_cents + addOnPrice + tax, + recurlySubscription.currency, + locale + ) } } diff --git a/services/web/app/src/util/currency.js b/services/web/app/src/util/currency.js new file mode 100644 index 0000000000..4a9972d423 --- /dev/null +++ b/services/web/app/src/util/currency.js @@ -0,0 +1,34 @@ +/** + * This file is duplicated from services/web/frontend/js/shared/utils/currency.ts + */ + +/** + * @typedef {import('@/shared/utils/currency').CurrencyCode} CurrencyCode + */ + +/** + * @param {number} amount + * @param {CurrencyCode} currency + * @param {string} locale + * @param {boolean} stripIfInteger + * @returns {string} + */ +function formatCurrencyLocalized(amount, currency, locale, stripIfInteger) { + if (stripIfInteger && Number.isInteger(amount)) { + return amount.toLocaleString(locale, { + style: 'currency', + currency, + minimumFractionDigits: 0, + currencyDisplay: 'narrowSymbol', + }) + } + return amount.toLocaleString(locale, { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + }) +} + +module.exports = { + formatCurrencyLocalized, +} diff --git a/services/web/app/templates/plans/groups.json b/services/web/app/templates/plans/groups.json index fc85c8086a..b19d5a291d 100644 --- a/services/web/app/templates/plans/groups.json +++ b/services/web/app/templates/plans/groups.json @@ -300,6 +300,29 @@ "price_in_cents": 755000 } }, + "PEN": { + "2": { + "price_in_cents": 134200 + }, + "3": { + "price_in_cents": 201300 + }, + "4": { + "price_in_cents": 268400 + }, + "5": { + "price_in_cents": 335500 + }, + "10": { + "price_in_cents": 374000 + }, + "20": { + "price_in_cents": 690000 + }, + "50": { + "price_in_cents": 1580000 + } + }, "SEK": { "2": { "price_in_cents": 401600 @@ -368,29 +391,6 @@ "50": { "price_in_cents": 655000 } - }, - "PEN": { - "2": { - "price_in_cents": 134200 - }, - "3": { - "price_in_cents": 201300 - }, - "4": { - "price_in_cents": 268400 - }, - "5": { - "price_in_cents": 335500 - }, - "10": { - "price_in_cents": 374000 - }, - "20": { - "price_in_cents": 690000 - }, - "50": { - "price_in_cents": 1580000 - } } }, "collaborator": { @@ -693,6 +693,29 @@ "price_in_cents": 390000 } }, + "PEN": { + "2": { + "price_in_cents": 64200 + }, + "3": { + "price_in_cents": 96300 + }, + "4": { + "price_in_cents": 128400 + }, + "5": { + "price_in_cents": 160500 + }, + "10": { + "price_in_cents": 179000 + }, + "20": { + "price_in_cents": 330000 + }, + "50": { + "price_in_cents": 755000 + } + }, "SEK": { "2": { "price_in_cents": 202800 @@ -761,29 +784,6 @@ "50": { "price_in_cents": 325000 } - }, - "PEN": { - "2": { - "price_in_cents": 64200 - }, - "3": { - "price_in_cents": 96300 - }, - "4": { - "price_in_cents": 128400 - }, - "5": { - "price_in_cents": 160500 - }, - "10": { - "price_in_cents": 179000 - }, - "20": { - "price_in_cents": 330000 - }, - "50": { - "price_in_cents": 755000 - } } } }, @@ -924,7 +924,7 @@ "price_in_cents": 947880000 }, "50": { - "price_in_cents": 2170000000 + "price_in_cents": 2000000000 } }, "DKK": { @@ -1088,6 +1088,29 @@ "price_in_cents": 1260000 } }, + "PEN": { + "2": { + "price_in_cents": 134200 + }, + "3": { + "price_in_cents": 201300 + }, + "4": { + "price_in_cents": 268400 + }, + "5": { + "price_in_cents": 335500 + }, + "10": { + "price_in_cents": 623000 + }, + "20": { + "price_in_cents": 1150000 + }, + "50": { + "price_in_cents": 2635000 + } + }, "SEK": { "2": { "price_in_cents": 401600 @@ -1156,29 +1179,6 @@ "50": { "price_in_cents": 1095000 } - }, - "PEN": { - "2": { - "price_in_cents": 134200 - }, - "3": { - "price_in_cents": 201300 - }, - "4": { - "price_in_cents": 268400 - }, - "5": { - "price_in_cents": 335500 - }, - "10": { - "price_in_cents": 623000 - }, - "20": { - "price_in_cents": 1150000 - }, - "50": { - "price_in_cents": 2635000 - } } }, "collaborator": { @@ -1481,6 +1481,29 @@ "price_in_cents": 655000 } }, + "PEN": { + "2": { + "price_in_cents": 64200 + }, + "3": { + "price_in_cents": 96300 + }, + "4": { + "price_in_cents": 128400 + }, + "5": { + "price_in_cents": 160500 + }, + "10": { + "price_in_cents": 298000 + }, + "20": { + "price_in_cents": 550000 + }, + "50": { + "price_in_cents": 1260000 + } + }, "SEK": { "2": { "price_in_cents": 202800 @@ -1549,29 +1572,6 @@ "50": { "price_in_cents": 545000 } - }, - "PEN": { - "2": { - "price_in_cents": 64200 - }, - "3": { - "price_in_cents": 96300 - }, - "4": { - "price_in_cents": 128400 - }, - "5": { - "price_in_cents": 160500 - }, - "10": { - "price_in_cents": 298000 - }, - "20": { - "price_in_cents": 550000 - }, - "50": { - "price_in_cents": 1260000 - } } } } diff --git a/services/web/app/views/subscriptions/interstitial-payment.pug b/services/web/app/views/subscriptions/interstitial-payment.pug index fbc8f2f1b0..e64ea4cb3d 100644 --- a/services/web/app/views/subscriptions/interstitial-payment.pug +++ b/services/web/app/views/subscriptions/interstitial-payment.pug @@ -48,6 +48,9 @@ block content table.card.plans-v2-table.plans-v2-table-individual +plans_v2_table('annual', interstitialPaymentConfig) + if (showCurrencyAndPaymentMethods) + +currency_and_payment_methods() + //- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div .invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 6112638cd1..7e798f6504 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -31,20 +31,8 @@ block content h1.text-capitalize(ng-non-bindable) #{translate('choose_your_plan')} include ./plans/_cards_controls_tables - .row.row-spaced-large.text-centered - .col-xs-12 - p.text-centered - strong #{translate("all_prices_displayed_are_in_currency", {recommendedCurrency})} - |   - span #{translate("subject_to_additional_vat")} - i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })} - i.fa.fa-cc-visa.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })} - i.fa.fa-cc-amex.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })} - i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })} + + +currency_and_payment_methods() include ./plans/_university_info diff --git a/services/web/app/views/subscriptions/plans/_mixins.pug b/services/web/app/views/subscriptions/plans/_mixins.pug index 8712097f55..d6abc13116 100644 --- a/services/web/app/views/subscriptions/plans/_mixins.pug +++ b/services/web/app/views/subscriptions/plans/_mixins.pug @@ -8,7 +8,24 @@ mixin features_premium li + #{translate('more').toLowerCase()} mixin gen_localized_price_for_plan_view(plan, view) - span #{settings.localizedPlanPricing[recommendedCurrency][plan][view]} + span #{formatCurrency(settings.localizedPlanPricing[recommendedCurrency][plan][view], recommendedCurrency, language, true)} + +mixin currency_and_payment_methods() + .row.row-spaced-large.text-centered + .col-xs-12 + p.text-centered + strong #{translate("all_prices_displayed_are_in_currency", { recommendedCurrency })} + |   + span #{translate("subject_to_additional_vat")} + i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")   + span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })} + i.fa.fa-cc-visa.fa-2x(aria-hidden="true")   + span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })} + i.fa.fa-cc-amex.fa-2x(aria-hidden="true")   + span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })} + i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")   + span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })} + mixin plans_v2_table(period, config) - var baseColspan = config.baseColspan || 1 diff --git a/services/web/frontend/js/features/plans/group-plan-modal/index.js b/services/web/frontend/js/features/plans/group-plan-modal/index.js index 05f55742d5..b979852ed6 100644 --- a/services/web/frontend/js/features/plans/group-plan-modal/index.js +++ b/services/web/frontend/js/features/plans/group-plan-modal/index.js @@ -1,7 +1,12 @@ import getMeta from '../../../utils/meta' import { swapModal } from '../../utils/swapModal' import * as eventTracking from '../../../infrastructure/event-tracking' -import { createLocalizedGroupPlanPrice } from '../utils/group-plan-pricing' +import { + createLocalizedGroupPlanPrice, + formatCurrencyDefault, +} from '../utils/group-plan-pricing' +import { getSplitTestVariant } from '@/utils/splitTestUtils' +import { formatCurrencyLocalized } from '@/shared/utils/currency' function getFormValues() { const modalEl = document.querySelector('[data-ol-group-plan-modal]') @@ -20,12 +25,18 @@ export function updateGroupModalPlanPricing() { const modalEl = document.querySelector('[data-ol-group-plan-modal]') const { planCode, size, currency, usage } = getFormValues() + const localCcyVariant = getSplitTestVariant('local-ccy-format') + const { localizedPrice, localizedPerUserPrice } = createLocalizedGroupPlanPrice({ plan: planCode, licenseSize: size, currency, usage, + formatCurrency: + localCcyVariant === 'enabled' + ? formatCurrencyLocalized + : formatCurrencyDefault, }) modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => { diff --git a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js index 181ca7fa26..d418cf5282 100644 --- a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js +++ b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js @@ -1,5 +1,47 @@ import getMeta from '../../../utils/meta' +/** + * @typedef {import('@/shared/utils/currency').CurrencyCode} CurrencyCode + */ + +// plan: 'collaborator' or 'professional' +// the rest of available arguments can be seen in the groupPlans value +/** + * @param {'collaborator' | 'professional'} plan + * @param {string} licenseSize + * @param {CurrencyCode} currency + * @param {'enterprise' | 'educational'} usage + * @param {string?} locale + * @param {(amount: number, currency: CurrencyCode, locale: string, includeSymbol: boolean) => string} formatCurrency + * @returns {{localizedPrice: string, localizedPerUserPrice: string}} + */ +export function createLocalizedGroupPlanPrice({ + plan, + licenseSize, + currency, + usage, + locale = window.i18n.currentLangCode || 'en', + formatCurrency, +}) { + const groupPlans = getMeta('ol-groupPlans') + const priceInCents = + groupPlans[usage][plan][currency][licenseSize].price_in_cents + + const price = priceInCents / 100 + const perUserPrice = price / parseInt(licenseSize) + + /** + * @param {number} price + * @returns {string} + */ + const formatPrice = price => formatCurrency(price, currency, locale, true) + + return { + localizedPrice: formatPrice(price), + localizedPerUserPrice: formatPrice(perUserPrice), + } +} + const LOCALES = { BRL: 'pt-BR', MXN: 'es-MX', @@ -8,30 +50,12 @@ const LOCALES = { PEN: 'es-PE', } -// plan: 'collaborator' or 'professional' -// the rest of available arguments can be seen in the groupPlans value -export function createLocalizedGroupPlanPrice({ - plan, - licenseSize, - currency, - usage, -}) { - const groupPlans = getMeta('ol-groupPlans') +/** + * @param {number} amount + * @param {string} currency + */ +export function formatCurrencyDefault(amount, currency) { const currencySymbols = getMeta('ol-currencySymbols') - const priceInCents = - groupPlans[usage][plan][currency][licenseSize].price_in_cents - - const price = priceInCents / 100 - const perUserPrice = price / parseInt(licenseSize) - - const strPrice = price.toFixed() - let strPerUserPrice = '' - - if (Number.isInteger(perUserPrice)) { - strPerUserPrice = String(perUserPrice) - } else { - strPerUserPrice = perUserPrice.toFixed(2) - } const currencySymbol = currencySymbols[currency] @@ -42,35 +66,19 @@ export function createLocalizedGroupPlanPrice({ case 'CLP': case 'PEN': // Test using toLocaleString to format currencies for new LATAM regions - return { - localizedPrice: price.toLocaleString(LOCALES[currency], { - style: 'currency', - currency, - minimumFractionDigits: 0, - }), - localizedPerUserPrice: perUserPrice.toLocaleString(LOCALES[currency], { - style: 'currency', - currency, - minimumFractionDigits: Number.isInteger(perUserPrice) ? 0 : null, - }), - } + return amount.toLocaleString(LOCALES[currency], { + style: 'currency', + currency, + minimumFractionDigits: Number.isInteger(amount) ? 0 : null, + }) case 'CHF': - return { - localizedPrice: `${currencySymbol} ${strPrice}`, - localizedPerUserPrice: `${currencySymbol} ${strPerUserPrice}`, - } + return `${currencySymbol} ${amount}` case 'DKK': case 'SEK': case 'NOK': - return { - localizedPrice: `${strPrice} ${currencySymbol}`, - localizedPerUserPrice: `${strPerUserPrice} ${currencySymbol}`, - } + return `${amount} ${currencySymbol}` default: { - return { - localizedPrice: `${currencySymbol}${strPrice}`, - localizedPerUserPrice: `${currencySymbol}${strPerUserPrice}`, - } + return `${currencySymbol}${amount}` } } } diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index fbb13885ee..77dc69408e 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -7,6 +7,7 @@ import { useMemo, useState, } from 'react' +import { useTranslation } from 'react-i18next' import { CustomSubscription, ManagedGroupSubscription, @@ -22,12 +23,15 @@ import { Institution as ManagedInstitution } from '../components/dashboard/manag import { Publisher as ManagedPublisher } from '../components/dashboard/managed-publishers' import getMeta from '../../../utils/meta' import { + formatCurrencyDefault, loadDisplayPriceWithTaxPromise, loadGroupDisplayPriceWithTaxPromise, } from '../util/recurly-pricing' import { isRecurlyLoaded } from '../util/is-recurly-loaded' import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids' import { debugConsole } from '@/utils/debugging' +import { getSplitTestVariant } from '@/utils/splitTestUtils' +import { formatCurrencyLocalized } from '@/shared/utils/currency' type SubscriptionDashboardContextValue = { groupPlanToChangeToCode: string @@ -76,11 +80,17 @@ export const SubscriptionDashboardContext = createContext< SubscriptionDashboardContextValue | undefined >(undefined) +const getFormatCurrencies = () => + getSplitTestVariant('local-ccy-format') === 'enabled' + ? formatCurrencyLocalized + : formatCurrencyDefault + export function SubscriptionDashboardProvider({ children, }: { children: ReactNode }) { + const { i18n } = useTranslation() const [modalIdShown, setModalIdShown] = useState< SubscriptionDashModalIds | undefined >() @@ -154,6 +164,7 @@ export function SubscriptionDashboardProvider({ plansWithoutDisplayPrice && personalSubscription?.recurly ) { + const formatCurrency = getFormatCurrencies() const { currency, taxRate } = personalSubscription.recurly const fetchPlansDisplayPrices = async () => { for (const plan of plansWithoutDisplayPrice) { @@ -161,10 +172,16 @@ export function SubscriptionDashboardProvider({ const priceData = await loadDisplayPriceWithTaxPromise( plan.planCode, currency, - taxRate + taxRate, + i18n.language, + formatCurrency ) - if (priceData?.totalForDisplay) { - plan.displayPrice = priceData.totalForDisplay + if (priceData?.totalAsNumber !== undefined) { + plan.displayPrice = formatCurrency( + priceData.totalAsNumber, + currency, + i18n.language + ) } } catch (error) { debugConsole.error(error) @@ -175,7 +192,7 @@ export function SubscriptionDashboardProvider({ } fetchPlansDisplayPrices().catch(debugConsole.error) } - }, [personalSubscription, plansWithoutDisplayPrice]) + }, [personalSubscription, plansWithoutDisplayPrice, i18n.language]) useEffect(() => { if ( @@ -192,12 +209,15 @@ export function SubscriptionDashboardProvider({ setGroupPlanToChangeToPriceError(false) let priceData try { + const formatCurrency = getFormatCurrencies() priceData = await loadGroupDisplayPriceWithTaxPromise( groupPlanToChangeToCode, currency, taxRate, groupPlanToChangeToSize, - groupPlanToChangeToUsage + groupPlanToChangeToUsage, + i18n.language, + formatCurrency ) } catch (e) { debugConsole.error(e) @@ -213,6 +233,7 @@ export function SubscriptionDashboardProvider({ groupPlanToChangeToSize, personalSubscription, groupPlanToChangeToCode, + i18n.language, ]) const updateManagedInstitution = useCallback( diff --git a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts index d4bec39a5b..22523c09ee 100644 --- a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts +++ b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts @@ -20,17 +20,32 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) { }) } -function priceToWithCents(price: number) { - return price % 1 !== 0 ? price.toFixed(2) : price +type FormatCurrency = ( + price: number, + currency: CurrencyCode, + locale: string, + stripIfInteger?: boolean +) => string + +export const formatCurrencyDefault: FormatCurrency = ( + price: number, + currency: CurrencyCode, + _locale: string, + stripIfInteger = false +) => { + const currencySymbol = currencies[currency] + const number = + stripIfInteger && price % 1 === 0 ? Number(price) : price.toFixed(2) + return `${currencySymbol}${number}` } export function formatPriceForDisplayData( price: string, taxRate: number, - currencyCode: CurrencyCode + currencyCode: CurrencyCode, + locale: string, + formatCurrency: FormatCurrency ): PriceForDisplayData { - const currencySymbol = currencies[currencyCode] - const totalPriceExTax = parseFloat(price) let taxAmount = totalPriceExTax * taxRate if (isNaN(taxAmount)) { @@ -39,26 +54,30 @@ export function formatPriceForDisplayData( const totalWithTax = totalPriceExTax + taxAmount return { - totalForDisplay: `${currencySymbol}${priceToWithCents(totalWithTax)}`, + totalForDisplay: formatCurrency(totalWithTax, currencyCode, locale, true), totalAsNumber: totalWithTax, - subtotal: `${currencySymbol}${totalPriceExTax.toFixed(2)}`, - tax: `${currencySymbol}${taxAmount.toFixed(2)}`, + subtotal: formatCurrency(totalPriceExTax, currencyCode, locale), + tax: formatCurrency(taxAmount, currencyCode, locale), includesTax: taxAmount !== 0, } } function getPerUserDisplayPrice( totalPrice: number, - currencySymbol: string, - size: string + currency: CurrencyCode, + size: string, + locale: string, + formatCurrency: FormatCurrency ): string { - return `${currencySymbol}${priceToWithCents(totalPrice / parseInt(size))}` + return formatCurrency(totalPrice / parseInt(size), currency, locale, true) } export async function loadDisplayPriceWithTaxPromise( planCode: string, currencyCode: CurrencyCode, - taxRate: number + taxRate: number, + locale: string, + formatCurrency: FormatCurrency ) { if (!recurly) return @@ -67,7 +86,13 @@ export async function loadDisplayPriceWithTaxPromise( currencyCode )) as SubscriptionPricingState['price'] if (price) - return formatPriceForDisplayData(price.next.total, taxRate, currencyCode) + return formatPriceForDisplayData( + price.next.total, + taxRate, + currencyCode, + locale, + formatCurrency + ) } export async function loadGroupDisplayPriceWithTaxPromise( @@ -75,7 +100,9 @@ export async function loadGroupDisplayPriceWithTaxPromise( currencyCode: CurrencyCode, taxRate: number, size: string, - usage: string + usage: string, + locale: string, + formatCurrency: FormatCurrency ) { if (!recurly) return @@ -83,15 +110,18 @@ export async function loadGroupDisplayPriceWithTaxPromise( const price = await loadDisplayPriceWithTaxPromise( planCode, currencyCode, - taxRate + taxRate, + locale, + formatCurrency ) if (price) { - const currencySymbol = currencies[currencyCode] price.perUserDisplayPrice = getPerUserDisplayPrice( price.totalAsNumber, - currencySymbol, - size + currencyCode, + size, + locale, + formatCurrency ) } diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js index b29f90ef10..02417c2301 100644 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js +++ b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js @@ -1,7 +1,12 @@ import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' import '../../../../features/plans/plans-v2-group-plan-modal' -import { createLocalizedGroupPlanPrice } from '../../../../features/plans/utils/group-plan-pricing' +import { + createLocalizedGroupPlanPrice, + formatCurrencyDefault, +} from '../../../../features/plans/utils/group-plan-pricing' import getMeta from '../../../../utils/meta' +import { getSplitTestVariant } from '@/utils/splitTestUtils' +import { formatCurrencyLocalized } from '@/shared/utils/currency' const MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT = 10 @@ -21,6 +26,11 @@ export function updateMainGroupPlanPricing() { ? 'educational' : 'enterprise' + const localCcyVariant = getSplitTestVariant('local-ccy-format') + const formatCurrency = + localCcyVariant === 'enabled' + ? formatCurrencyLocalized + : formatCurrencyDefault const { localizedPrice: localizedPriceProfessional, localizedPerUserPrice: localizedPerUserPriceProfessional, @@ -29,6 +39,7 @@ export function updateMainGroupPlanPricing() { licenseSize, currency, usage, + formatCurrency, }) const { @@ -39,6 +50,7 @@ export function updateMainGroupPlanPricing() { licenseSize, currency, usage, + formatCurrency, }) document.querySelector( diff --git a/services/web/frontend/js/shared/utils/currency.ts b/services/web/frontend/js/shared/utils/currency.ts new file mode 100644 index 0000000000..abf52bcdbf --- /dev/null +++ b/services/web/frontend/js/shared/utils/currency.ts @@ -0,0 +1,39 @@ +export type CurrencyCode = + | 'AUD' + | 'BRL' + | 'CAD' + | 'CHF' + | 'CLP' + | 'COP' + | 'DKK' + | 'EUR' + | 'GBP' + | 'INR' + | 'MXN' + | 'NOK' + | 'NZD' + | 'PEN' + | 'SEK' + | 'SGD' + | 'USD' + +export function formatCurrencyLocalized( + amount: number, + currency: CurrencyCode, + locale: string, + stripIfInteger = false +): string { + if (stripIfInteger && Number.isInteger(amount)) { + return amount.toLocaleString(locale, { + style: 'currency', + currency, + minimumFractionDigits: 0, + currencyDisplay: 'narrowSymbol', + }) + } + return amount.toLocaleString(locale, { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + }) +} diff --git a/services/web/scripts/plan-prices/README.md b/services/web/scripts/plan-prices/README.md index 5bca9a308a..948911548f 100644 --- a/services/web/scripts/plan-prices/README.md +++ b/services/web/scripts/plan-prices/README.md @@ -9,7 +9,6 @@ The scripts will put the output results into the `output` folder. _Command_ `node plans.js -f fileName -o outputdir` - generates three json files: - `localizedPlanPricing.json` for `/services/web/config/settings.overrides.saas.js` -- `plans.json` for `/services/web/frontend/js/main/plans.js` - `groups.json` for `/services/web/app/templates/plans/groups.json` The input file can be in `.csv` or `.json` format diff --git a/services/web/scripts/plan-prices/plans.js b/services/web/scripts/plan-prices/plans.js index 542a07dd35..b1b2719fd8 100644 --- a/services/web/scripts/plan-prices/plans.js +++ b/services/web/scripts/plan-prices/plans.js @@ -39,118 +39,38 @@ const plansMap = { professional: 'professional', } -const currencies = { - USD: { - symbol: '$', - placement: 'before', - }, - EUR: { - symbol: '€', - placement: 'before', - }, - GBP: { - symbol: '£', - placement: 'before', - }, - SEK: { - symbol: ' kr', - placement: 'after', - }, - CAD: { - symbol: '$', - placement: 'before', - }, - NOK: { - symbol: ' kr', - placement: 'after', - }, - DKK: { - symbol: ' kr', - placement: 'after', - }, - AUD: { - symbol: '$', - placement: 'before', - }, - NZD: { - symbol: '$', - placement: 'before', - }, - CHF: { - symbol: 'Fr ', - placement: 'before', - }, - SGD: { - symbol: '$', - placement: 'before', - }, - INR: { - symbol: '₹', - placement: 'before', - }, - BRL: { - code: 'BRL', - locale: 'pt-BR', - symbol: 'R$ ', - placement: 'before', - }, - MXN: { - code: 'MXN', - locale: 'es-MX', - symbol: '$ ', - placement: 'before', - }, - COP: { - code: 'COP', - locale: 'es-CO', - symbol: '$ ', - placement: 'before', - }, - CLP: { - code: 'CLP', - locale: 'es-CL', - symbol: '$ ', - placement: 'before', - }, - PEN: { - code: 'PEN', - locale: 'es-PE', - symbol: 'S/ ', - placement: 'before', - }, -} - -const buildCurrencyValue = (amount, currency) => { - // Test using toLocaleString to format currencies for new LATAM regions - if (currency.locale && currency.code) { - return amount.toLocaleString(currency.locale, { - style: 'currency', - currency: currency.code, - minimumFractionDigits: 0, - }) - } - return currency.placement === 'before' - ? `${currency.symbol}${amount}` - : `${amount}${currency.symbol}` -} +const currencies = [ + 'AUD', + 'BRL', + 'CAD', + 'CHF', + 'CLP', + 'COP', + 'DKK', + 'EUR', + 'GBP', + 'INR', + 'MXN', + 'NOK', + 'NZD', + 'PEN', + 'SEK', + 'SGD', + 'USD', +] function generatePlans(workSheetJSON) { // localizedPlanPricing object for settings.overrides.saas.js const localizedPlanPricing = {} // plans object for main/plans.js - const plans = {} - for (const [currency, currencyDetails] of Object.entries(currencies)) { + for (const currency of currencies) { localizedPlanPricing[currency] = { - symbol: currencyDetails.symbol.trim(), free: { - monthly: buildCurrencyValue(0, currencyDetails), - annual: buildCurrencyValue(0, currencyDetails), + monthly: 0, + annual: 0, }, } - plans[currency] = { - symbol: currencyDetails.symbol.trim(), - } for (const [outputKey, actualKey] of Object.entries(plansMap)) { const monthlyPlan = workSheetJSON.find( @@ -174,24 +94,17 @@ function generatePlans(workSheetJSON) { `Missing currency "${currency}" for plan "${actualKeyAnnual}"` ) - const monthly = buildCurrencyValue(monthlyPlan[currency], currencyDetails) - const monthlyTimesTwelve = buildCurrencyValue( - monthlyPlan[currency] * 12, - currencyDetails - ) - const annual = buildCurrencyValue(annualPlan[currency], currencyDetails) + const monthly = Number(monthlyPlan[currency]) + const monthlyTimesTwelve = Number(monthlyPlan[currency] * 12) + const annual = Number(annualPlan[currency]) localizedPlanPricing[currency] = { ...localizedPlanPricing[currency], [outputKey]: { monthly, monthlyTimesTwelve, annual }, } - plans[currency] = { - ...plans[currency], - [outputKey]: { monthly, annual }, - } } } - return { localizedPlanPricing, plans } + return { localizedPlanPricing } } function generateGroupPlans(workSheetJSON) { @@ -199,25 +112,6 @@ function generateGroupPlans(workSheetJSON) { data.plan_code.startsWith('group') ) - const currencies = [ - 'AUD', - 'BRL', - 'CAD', - 'CHF', - 'CLP', - 'COP', - 'DKK', - 'EUR', - 'GBP', - 'INR', - 'MXN', - 'NOK', - 'NZD', - 'SEK', - 'SGD', - 'USD', - 'PEN', - ] const sizes = ['2', '3', '4', '5', '10', '20', '50'] const result = {} @@ -275,7 +169,7 @@ function writeFile(outputFile, data) { fs.writeFileSync(outputFile, data) } -const { localizedPlanPricing, plans } = generatePlans(input) +const { localizedPlanPricing } = generatePlans(input) const groupPlans = generateGroupPlans(input) if (argv.output) { @@ -291,10 +185,8 @@ if (argv.output) { process.exit(1) } writeFile(`${dir}/localizedPlanPricing.json`, formatJS(localizedPlanPricing)) - writeFile(`${dir}/plans.json`, formatJS(plans)) writeFile(`${dir}/groups.json`, formatJSON(groupPlans)) } else { - console.log('PLANS', plans) console.log('LOCALIZED', localizedPlanPricing) console.log('GROUP PLANS', JSON.stringify(groupPlans, null, 2)) } diff --git a/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts b/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts index 292e405e0b..2e72bba9c3 100644 --- a/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts +++ b/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai' import { formatPriceForDisplayData } from '../../../../../frontend/js/features/subscription/util/recurly-pricing' +import { formatCurrencyLocalized } from '@/shared/utils/currency' describe('formatPriceForDisplayData', function () { beforeEach(function () { @@ -9,11 +10,17 @@ describe('formatPriceForDisplayData', function () { window.metaAttributesCache = new Map() }) it('should handle no tax rate', function () { - const data = formatPriceForDisplayData('1000', 0, 'USD') + const data = formatPriceForDisplayData( + '1000', + 0, + 'USD', + 'en', + formatCurrencyLocalized + ) expect(data).to.deep.equal({ - totalForDisplay: '$1000', + totalForDisplay: '$1,000', totalAsNumber: 1000, - subtotal: '$1000.00', + subtotal: '$1,000.00', tax: '$0.00', includesTax: false, }) @@ -21,7 +28,13 @@ describe('formatPriceForDisplayData', function () { }) it('should handle a tax rate', function () { - const data = formatPriceForDisplayData('380', 0.2, 'EUR') + const data = formatPriceForDisplayData( + '380', + 0.2, + 'EUR', + 'en', + formatCurrencyLocalized + ) expect(data).to.deep.equal({ totalForDisplay: '€456', totalAsNumber: 456, @@ -32,7 +45,13 @@ describe('formatPriceForDisplayData', function () { }) it('should handle total with cents', function () { - const data = formatPriceForDisplayData('8', 0.2, 'EUR') + const data = formatPriceForDisplayData( + '8', + 0.2, + 'EUR', + 'en', + formatCurrencyLocalized + ) expect(data).to.deep.equal({ totalForDisplay: '€9.60', totalAsNumber: 9.6, diff --git a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js b/services/web/test/frontend/shared/utils/group-plan-pricing.test.js index 4d00006b63..de9db17b88 100644 --- a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js +++ b/services/web/test/frontend/shared/utils/group-plan-pricing.test.js @@ -1,5 +1,6 @@ import { expect } from 'chai' import { createLocalizedGroupPlanPrice } from '../../../../frontend/js/features/plans/utils/group-plan-pricing' +import { formatCurrencyLocalized } from '@/shared/utils/currency' describe('group-plan-pricing', function () { beforeEach(function () { @@ -44,11 +45,12 @@ describe('group-plan-pricing', function () { currency: 'CHF', licenseSize: '2', usage: 'enterprise', + formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ - localizedPrice: 'Fr 100', - localizedPerUserPrice: 'Fr 50', + localizedPrice: 'CHF 100', + localizedPerUserPrice: 'CHF 50', }) }) }) @@ -59,11 +61,12 @@ describe('group-plan-pricing', function () { currency: 'DKK', licenseSize: '2', usage: 'enterprise', + formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ - localizedPrice: '200 kr', - localizedPerUserPrice: '100 kr', + localizedPrice: 'kr 200', + localizedPerUserPrice: 'kr 100', }) }) }) @@ -74,6 +77,7 @@ describe('group-plan-pricing', function () { currency: 'USD', licenseSize: '2', usage: 'enterprise', + formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ diff --git a/services/web/test/unit/src/Settings/SettingsTests.js b/services/web/test/unit/src/Settings/SettingsTests.js index 8d4a4596ca..39e7efafd0 100644 --- a/services/web/test/unit/src/Settings/SettingsTests.js +++ b/services/web/test/unit/src/Settings/SettingsTests.js @@ -14,6 +14,35 @@ function clearSettingsCache() { settingsDeps.forEach(dep => delete require.cache[dep]) } +/** + * @param {any} value + * @returns {string} A string representation of the structure of the value + */ +function serializeTypes(value) { + if (typeof value === 'object') { + const keys = Object.keys(value).sort() + const types = keys.reduce((acc, key) => { + acc[key] = serializeTypes(value[key]) + return acc + }, {}) + return JSON.stringify(types) + } + if (Array.isArray(value)) { + return JSON.stringify(value.map(serializeTypes)) + } + return typeof value +} + +/** + * @param {any[]} objects + * @returns {boolean} Whether all objects have the same structure + */ +function haveSameStructure(objects) { + if (!objects.length) return true + const referenceStructure = serializeTypes(objects[0]) + return objects.every(obj => serializeTypes(obj) === referenceStructure) +} + describe('settings.defaults', function () { it('additional text extensions can be added via config', function () { clearSettingsCache() @@ -23,4 +52,35 @@ describe('settings.defaults', function () { expect(settings.textExtensions).to.include('abc') expect(settings.textExtensions).to.include('xyz') }) + + it('generates pricings with same structures', function () { + const settingsOverridesSaas = require('../../../../config/settings.overrides.saas.js') + const { localizedPlanPricing } = settingsOverridesSaas + + const pricingCurrencies = Object.keys(localizedPlanPricing) + expect(pricingCurrencies.sort()).to.eql([ + 'AUD', + 'BRL', + 'CAD', + 'CHF', + 'CLP', + 'COP', + 'DKK', + 'EUR', + 'GBP', + 'INR', + 'MXN', + 'NOK', + 'NZD', + 'PEN', + 'SEK', + 'SGD', + 'USD', + ]) + + const pricings = pricingCurrencies.map( + currency => localizedPlanPricing[currency] + ) + expect(haveSameStructure(pricings)).to.be.true + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js b/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js new file mode 100644 index 0000000000..972f6d8961 --- /dev/null +++ b/services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js @@ -0,0 +1,300 @@ +const chai = require('chai') +const SubscriptionFormatters = require('../../../../app/src/Features/Subscription/SubscriptionFormatters') + +const { expect } = chai + +/* + Users can select any language we support, regardless of the country where they are located. + Which mean that any combination of "supported language"-"supported currency" can be displayed + on the user's screen. + + Users located in the USA visiting https://fr.overleaf.com/user/subscription/plans + should see amounts in USD (because of their IP address), + but with French text, number formatting and currency formats (because of language choice). + (e.g. 1 000,00 $) + + Users located in the France visiting https://www.overleaf.com/user/subscription/plans + should see amounts in EUR (because of their IP address), + but with English text, number formatting and currency formats (because of language choice). + (e.g. €1,000.00) + */ + +describe('SubscriptionFormatters.formatPrice', function () { + describe('en', function () { + const format = currency => priceInCents => + SubscriptionFormatters.formatPriceLocalized(priceInCents, currency) + + describe('USD', function () { + const formatUSD = format('USD') + + it('should format basic amounts', function () { + expect(formatUSD(0)).to.equal('$0.00') + expect(formatUSD(1234)).to.equal('$12.34') + }) + + it('should format thousand separators', function () { + expect(formatUSD(100_000)).to.equal('$1,000.00') + expect(formatUSD(9_876_543_210)).to.equal('$98,765,432.10') + }) + + it('should format negative amounts', function () { + expect(formatUSD(-1)).to.equal('-$0.01') + expect(formatUSD(-1234)).to.equal('-$12.34') + }) + }) + + describe('EUR', function () { + const formatEUR = format('EUR') + + it('should format basic amounts', function () { + expect(formatEUR(0)).to.equal('€0.00') + expect(formatEUR(1234)).to.equal('€12.34') + }) + + it('should format thousand separators', function () { + expect(formatEUR(100_000)).to.equal('€1,000.00') + expect(formatEUR(9_876_543_210)).to.equal('€98,765,432.10') + }) + + it('should format negative amounts', function () { + expect(formatEUR(-1)).to.equal('-€0.01') + expect(formatEUR(-1234)).to.equal('-€12.34') + }) + }) + + describe('HUF', function () { + const formatHUF = format('HUF') + + it('should format basic amounts', function () { + expect(formatHUF(0)).to.equal('Ft 0.00') + expect(formatHUF(1234)).to.equal('Ft 12.34') + }) + + it('should format thousand separators', function () { + expect(formatHUF(100_000)).to.equal('Ft 1,000.00') + expect(formatHUF(9_876_543_210)).to.equal('Ft 98,765,432.10') + }) + + it('should format negative amounts', function () { + expect(formatHUF(-1)).to.equal('-Ft 0.01') + expect(formatHUF(-1234)).to.equal('-Ft 12.34') + }) + }) + + describe('CLP', function () { + const formatCLP = format('CLP') + + it('should format basic amounts', function () { + expect(formatCLP(0)).to.equal('$0') + expect(formatCLP(1234)).to.equal('$1,234') + }) + + it('should format thousand separators', function () { + expect(formatCLP(100_000)).to.equal('$100,000') + expect(formatCLP(9_876_543_210)).to.equal('$9,876,543,210') + }) + + it('should format negative amounts', function () { + expect(formatCLP(-1)).to.equal('-$1') + expect(formatCLP(-1234)).to.equal('-$1,234') + }) + }) + + describe('all currencies', function () { + it('should format 100 "minimal atomic units"', function () { + const amount = 100 + + // "no cents currencies" + expect(format('CLP')(amount)).to.equal('$100') + expect(format('JPY')(amount)).to.equal('¥100') + expect(format('KRW')(amount)).to.equal('₩100') + expect(format('VND')(amount)).to.equal('₫100') + + // other currencies + expect(format('AUD')(amount)).to.equal('$1.00') + expect(format('BRL')(amount)).to.equal('R$1.00') + expect(format('CAD')(amount)).to.equal('$1.00') + expect(format('CHF')(amount)).to.equal('CHF 1.00') + expect(format('CNY')(amount)).to.equal('¥1.00') + expect(format('COP')(amount)).to.equal('$1.00') + expect(format('DKK')(amount)).to.equal('kr 1.00') + expect(format('EUR')(amount)).to.equal('€1.00') + expect(format('GBP')(amount)).to.equal('£1.00') + expect(format('HUF')(amount)).to.equal('Ft 1.00') + expect(format('IDR')(amount)).to.equal('Rp 1.00') + expect(format('INR')(amount)).to.equal('₹1.00') + expect(format('MXN')(amount)).to.equal('$1.00') + expect(format('MYR')(amount)).to.equal('RM 1.00') + expect(format('NOK')(amount)).to.equal('kr 1.00') + expect(format('NZD')(amount)).to.equal('$1.00') + expect(format('PEN')(amount)).to.equal('PEN 1.00') + expect(format('PHP')(amount)).to.equal('₱1.00') + expect(format('SEK')(amount)).to.equal('kr 1.00') + expect(format('SGD')(amount)).to.equal('$1.00') + expect(format('THB')(amount)).to.equal('฿1.00') + expect(format('USD')(amount)).to.equal('$1.00') + }) + + it('should format 123_456_789.987_654 "minimal atomic units"', function () { + const amount = 123_456_789.987_654 + + // "no cents currencies" + expect(format('CLP')(amount)).to.equal('$123,456,790') + expect(format('JPY')(amount)).to.equal('¥123,456,790') + expect(format('KRW')(amount)).to.equal('₩123,456,790') + expect(format('VND')(amount)).to.equal('₫123,456,790') + + // other currencies + expect(format('AUD')(amount)).to.equal('$1,234,567.90') + expect(format('BRL')(amount)).to.equal('R$1,234,567.90') + expect(format('CAD')(amount)).to.equal('$1,234,567.90') + expect(format('CHF')(amount)).to.equal('CHF 1,234,567.90') + expect(format('CNY')(amount)).to.equal('¥1,234,567.90') + expect(format('COP')(amount)).to.equal('$1,234,567.90') + expect(format('DKK')(amount)).to.equal('kr 1,234,567.90') + expect(format('EUR')(amount)).to.equal('€1,234,567.90') + expect(format('GBP')(amount)).to.equal('£1,234,567.90') + expect(format('HUF')(amount)).to.equal('Ft 1,234,567.90') + expect(format('IDR')(amount)).to.equal('Rp 1,234,567.90') + expect(format('INR')(amount)).to.equal('₹1,234,567.90') + expect(format('MXN')(amount)).to.equal('$1,234,567.90') + expect(format('MYR')(amount)).to.equal('RM 1,234,567.90') + expect(format('NOK')(amount)).to.equal('kr 1,234,567.90') + expect(format('NZD')(amount)).to.equal('$1,234,567.90') + expect(format('PEN')(amount)).to.equal('PEN 1,234,567.90') + expect(format('PHP')(amount)).to.equal('₱1,234,567.90') + expect(format('SEK')(amount)).to.equal('kr 1,234,567.90') + expect(format('SGD')(amount)).to.equal('$1,234,567.90') + expect(format('THB')(amount)).to.equal('฿1,234,567.90') + expect(format('USD')(amount)).to.equal('$1,234,567.90') + }) + }) + }) + + describe('fr', function () { + const format = currency => priceInCents => + SubscriptionFormatters.formatPriceLocalized(priceInCents, currency, 'fr') + + describe('USD', function () { + const formatUSD = format('USD') + + it('should format basic amounts', function () { + expect(formatUSD(0)).to.equal('0,00 $') + expect(formatUSD(1234)).to.equal('12,34 $') + }) + + it('should format thousand separators', function () { + expect(formatUSD(100_000)).to.equal('1 000,00 $') + expect(formatUSD(9_876_543_210)).to.equal('98 765 432,10 $') + }) + + it('should format negative amounts', function () { + expect(formatUSD(-1)).to.equal('-0,01 $') + expect(formatUSD(-1234)).to.equal('-12,34 $') + }) + }) + + describe('EUR', function () { + const formatEUR = format('EUR') + + it('should format basic amounts', function () { + expect(formatEUR(0)).to.equal('0,00 €') + expect(formatEUR(1234)).to.equal('12,34 €') + }) + + it('should format thousand separators', function () { + expect(formatEUR(100_000)).to.equal('1 000,00 €') + expect(formatEUR(9_876_543_210)).to.equal('98 765 432,10 €') + }) + + it('should format negative amounts', function () { + expect(formatEUR(-1)).to.equal('-0,01 €') + expect(formatEUR(-1234)).to.equal('-12,34 €') + }) + }) + + describe('HUF', function () { + const formatHUF = format('HUF') + + it('should format basic amounts', function () { + expect(formatHUF(0)).to.equal('0,00 Ft') + expect(formatHUF(1234)).to.equal('12,34 Ft') + }) + + it('should format thousand separators', function () { + expect(formatHUF(100_000)).to.equal('1 000,00 Ft') + expect(formatHUF(9_876_543_210)).to.equal('98 765 432,10 Ft') + }) + + it('should format negative amounts', function () { + expect(formatHUF(-1)).to.equal('-0,01 Ft') + expect(formatHUF(-1234)).to.equal('-12,34 Ft') + }) + }) + + describe('CLP', function () { + const formatCLP = format('CLP') + + it('should format basic amounts', function () { + expect(formatCLP(0)).to.equal('0 $') + expect(formatCLP(1234)).to.equal('1 234 $') + }) + + it('should format thousand separators', function () { + expect(formatCLP(100_000)).to.equal('100 000 $') + expect(formatCLP(9_876_543_210)).to.equal('9 876 543 210 $') + }) + + it('should format negative amounts', function () { + expect(formatCLP(-1)).to.equal('-1 $') + expect(formatCLP(-1234)).to.equal('-1 234 $') + }) + }) + + describe('all currencies', function () { + it('should format 100 "minimal atomic units"', function () { + const amount = 100 + + // "no cents currencies" + expect(format('CLP')(amount)).to.equal('100 $') + expect(format('JPY')(amount)).to.equal('100 ¥') + expect(format('KRW')(amount)).to.equal('100 ₩') + expect(format('VND')(amount)).to.equal('100 ₫') + + // other currencies + expect(format('AUD')(amount)).to.equal('1,00 $') + expect(format('BRL')(amount)).to.equal('1,00 R$') + expect(format('CAD')(amount)).to.equal('1,00 $') + expect(format('CHF')(amount)).to.equal('1,00 CHF') + expect(format('CNY')(amount)).to.equal('1,00 ¥') + expect(format('COP')(amount)).to.equal('1,00 $') + + expect(format('EUR')(amount)).to.equal('1,00 €') + expect(format('GBP')(amount)).to.equal('1,00 £') + expect(format('USD')(amount)).to.equal('1,00 $') + }) + + it('should format 123_456_789.987_654 "minimal atomic units"', function () { + const amount = 123_456_789.987_654 + + // "no cents currencies" + expect(format('CLP')(amount)).to.equal('123 456 790 $') + expect(format('JPY')(amount)).to.equal('123 456 790 ¥') + expect(format('KRW')(amount)).to.equal('123 456 790 ₩') + expect(format('VND')(amount)).to.equal('123 456 790 ₫') + + // other currencies + expect(format('AUD')(amount)).to.equal('1 234 567,90 $') + expect(format('BRL')(amount)).to.equal('1 234 567,90 R$') + expect(format('CAD')(amount)).to.equal('1 234 567,90 $') + expect(format('CHF')(amount)).to.equal('1 234 567,90 CHF') + expect(format('CNY')(amount)).to.equal('1 234 567,90 ¥') + expect(format('COP')(amount)).to.equal('1 234 567,90 $') + + expect(format('EUR')(amount)).to.equal('1 234 567,90 €') + expect(format('GBP')(amount)).to.equal('1 234 567,90 £') + expect(format('USD')(amount)).to.equal('1 234 567,90 $') + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index 765ad0cb94..7ec420543c 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -1,5 +1,6 @@ const SandboxedModule = require('sandboxed-module') const { expect } = require('chai') +const { formatCurrencyLocalized } = require('../../../../app/src/util/currency') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionHelper' @@ -151,16 +152,20 @@ describe('SubscriptionHelper', function () { describe('CHF currency', function () { it('should return the correct localized price for every plan', function () { const localizedPrice = - this.SubscriptionHelper.generateInitialLocalizedGroupPrice('CHF') + this.SubscriptionHelper.generateInitialLocalizedGroupPrice( + 'CHF', + 'fr', + formatCurrencyLocalized + ) expect(localizedPrice).to.deep.equal({ price: { - collaborator: 'Fr 10', - professional: 'Fr 100', + collaborator: '10 CHF', + professional: '100 CHF', }, pricePerUser: { - collaborator: 'Fr 5', - professional: 'Fr 50', + collaborator: '5 CHF', + professional: '50 CHF', }, }) }) @@ -169,16 +174,20 @@ describe('SubscriptionHelper', function () { describe('DKK currency', function () { it('should return the correct localized price for every plan', function () { const localizedPrice = - this.SubscriptionHelper.generateInitialLocalizedGroupPrice('DKK') + this.SubscriptionHelper.generateInitialLocalizedGroupPrice( + 'DKK', + 'da', + formatCurrencyLocalized + ) expect(localizedPrice).to.deep.equal({ price: { - collaborator: '20 kr', - professional: '200 kr', + collaborator: '20 kr.', + professional: '200 kr.', }, pricePerUser: { - collaborator: '10 kr', - professional: '100 kr', + collaborator: '10 kr.', + professional: '100 kr.', }, }) }) @@ -187,16 +196,20 @@ describe('SubscriptionHelper', function () { describe('SEK currency', function () { it('should return the correct localized price for every plan', function () { const localizedPrice = - this.SubscriptionHelper.generateInitialLocalizedGroupPrice('SEK') + this.SubscriptionHelper.generateInitialLocalizedGroupPrice( + 'SEK', + 'sv', + formatCurrencyLocalized + ) expect(localizedPrice).to.deep.equal({ price: { - collaborator: '30 kr', - professional: '300 kr', + collaborator: '30 kr', + professional: '300 kr', }, pricePerUser: { - collaborator: '15 kr', - professional: '150 kr', + collaborator: '15 kr', + professional: '150 kr', }, }) }) @@ -205,16 +218,22 @@ describe('SubscriptionHelper', function () { describe('NOK currency', function () { it('should return the correct localized price for every plan', function () { const localizedPrice = - this.SubscriptionHelper.generateInitialLocalizedGroupPrice('NOK') + this.SubscriptionHelper.generateInitialLocalizedGroupPrice( + 'NOK', + // there seem to be possible inconsistencies with the CI + // maybe it depends on what languages are installed on the server? + 'en', + formatCurrencyLocalized + ) expect(localizedPrice).to.deep.equal({ price: { - collaborator: '40 kr', - professional: '400 kr', + collaborator: 'kr 40', + professional: 'kr 400', }, pricePerUser: { - collaborator: '20 kr', - professional: '200 kr', + collaborator: 'kr 20', + professional: 'kr 200', }, }) }) @@ -223,7 +242,11 @@ describe('SubscriptionHelper', function () { describe('other supported currencies', function () { it('should return the correct localized price for every plan', function () { const localizedPrice = - this.SubscriptionHelper.generateInitialLocalizedGroupPrice('USD') + this.SubscriptionHelper.generateInitialLocalizedGroupPrice( + 'USD', + 'en', + formatCurrencyLocalized + ) expect(localizedPrice).to.deep.equal({ price: {