[web] Use localized number formatting for currencies (#17622)

* Add a unit test on `SubscriptionFormatters.formatPrice`

* Add JSDoc to `formatPrice`

Also: Name the functions before exporting:
This fixes my IDE (WebStorm) navigation

* Make `'USD'` the default param instead of reassigning

* Create `formatCurrency` function

* Use `formatCurrency` in SubscriptionFormatters

* Use an `isNoCentsCurrency` logic for `CLP` `JPY` `KRW` `VND`

And remove custom `CLP` logic and locale

* Add `locale` param to `formatPrice`

* Generate `groups.json` and `localizedPlanPricing.json`

```
bin/exec web node ./scripts/recurly/recurly_prices.js --download -o prices.json
bin/exec web node ./scripts/plan-prices/plans.js -f ../../prices.json -o dir
```

* Update scripts/plan-prices/plans.js to generate numbers instead of localized amounts

* Generate `groups.json` and `localizedPlanPricing.json`

```
bin/exec web node ./scripts/recurly/recurly_prices.js --download -o prices.json
bin/exec web node ./scripts/plan-prices/plans.js -f ../../prices.json -o dir
```

* Remove generation of `plans.json`

As /services/web/frontend/js/main/plans.js was removed in https://github.com/overleaf/internal/pull/12593

* Sort currencies in alphabetical order in scripts/plan-prices/plans.js

* Generate `groups.json` and `localizedPlanPricing.json`

```
bin/exec web node ./scripts/recurly/recurly_prices.js --download -o prices.json
bin/exec web node ./scripts/plan-prices/plans.js -f ../../prices.json -o dir
```

* Use `formatCurrency` in price-summary.tsx

* Use `formatCurrency` in Subscription Pug files

* Fix unit tests SubscriptionHelperTests.js

* Remove unused `currencySymbol`

* Change to `formatCurrency` in other React components

* Add `CurrencyCode` JSDoc types

* Duplicate `formatCurrency` into services/web/app/src/util

* Wrap tests in a top-level describe block

* Use `narrowSymbol`

* Fix tests with `narrowSymbol` expects

* Revert deletion of old `formatPrice` in SubscriptionFormatters.js

* Rename `formatCurrency` -> `formatCurrencyLocalized`

* Revert deletion of `CurrencySymbol`

* Add split-test in SubscriptionController.js

* Add split-test in SubscriptionViewModelBuilder.js

* Add split-test in plans

* Add split-test in subscription-dashboard-context.tsx

* Add split-test in 4 more components

* Update tests

* Show currency and payment methods in interstitial page

* Fix `–` being printed. Use `–` instead

* Fix test with NOK

* Storybook: Fix missing `SplitTestProvider`

* Storybook: Revert "Remove unused `currencySymbol`"

This reverts commit e55387d4753f97bbf8e39e0fdc3ad17312122aaa.

* Replace `getSplitTestVariant` by `useSplitTestContext`

* Use parameterize currencyFormat in `generateInitialLocalizedGroupPrice`

* Fixup import paths of `formatCurrencyLocalized`

* Replace `% 1 === 0` by `Number.isInteger`

* Add comment explaining that any combinations of languages/currencies could happen

* Fixup after rebase: import `useSplitTestContext`

* Revert "Remove SplitTestProvider from subscription root"

This reverts commit be9f378fda715b86589ab0759737581c72321d87.

* Revert "Remove split test provider from some tests"

This reverts commit 985522932b550cfd38fa6a4f4c3d2ebaee6ff7df.

GitOrigin-RevId: 59a83cbbe0f7cc7e45f189c654e23fcf9bfa37af
This commit is contained in:
Antoine Clausse 2024-04-18 10:13:51 +02:00 committed by Copybot
parent 76955c814a
commit b2ef7a935f
22 changed files with 985 additions and 437 deletions

View file

@ -21,6 +21,8 @@ const SubscriptionHelper = require('./SubscriptionHelper')
const AuthorizationManager = require('../Authorization/AuthorizationManager') const AuthorizationManager = require('../Authorization/AuthorizationManager')
const Modules = require('../../infrastructure/Modules') const Modules = require('../../infrastructure/Modules')
const async = require('async') const async = require('async')
const { formatCurrencyLocalized } = require('../../util/currency')
const SubscriptionFormatters = require('./SubscriptionFormatters')
const groupPlanModalOptions = Settings.groupPlanModalOptions const groupPlanModalOptions = Settings.groupPlanModalOptions
const validGroupPlanModalOptions = { const validGroupPlanModalOptions = {
@ -31,6 +33,8 @@ const validGroupPlanModalOptions = {
} }
async function plansPage(req, res) { async function plansPage(req, res) {
const language = req.i18n.language || 'en'
const plans = SubscriptionViewModelBuilder.buildPlansList() const plans = SubscriptionViewModelBuilder.buildPlansList()
let currency = null let currency = null
const queryCurrency = req.query.currency?.toUpperCase() const queryCurrency = req.query.currency?.toUpperCase()
@ -81,6 +85,16 @@ async function plansPage(req, res) {
geoPricingLATAMTestVariant === 'latam' && geoPricingLATAMTestVariant === 'latam' &&
['MX', 'CO', 'CL', 'PE'].includes(countryCode) ['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', { res.render('subscriptions/plans', {
title: 'plans_and_pricing', title: 'plans_and_pricing',
currentView, currentView,
@ -88,6 +102,8 @@ async function plansPage(req, res) {
itm_content: req.query?.itm_content, itm_content: req.query?.itm_content,
itm_referrer: req.query?.itm_referrer, itm_referrer: req.query?.itm_referrer,
itm_campaign: 'plans', itm_campaign: 'plans',
language,
formatCurrency,
recommendedCurrency: currency, recommendedCurrency: currency,
planFeatures, planFeatures,
plansConfig, plansConfig,
@ -95,7 +111,11 @@ async function plansPage(req, res) {
groupPlanModalOptions, groupPlanModalOptions,
groupPlanModalDefaults, groupPlanModalDefaults,
initialLocalizedGroupPrice: initialLocalizedGroupPrice:
SubscriptionHelper.generateInitialLocalizedGroupPrice(currency), SubscriptionHelper.generateInitialLocalizedGroupPrice(
currency ?? 'USD',
language,
formatCurrency
),
showInrGeoBanner: countryCode === 'IN', showInrGeoBanner: countryCode === 'IN',
showBrlGeoBanner: countryCode === 'BR', showBrlGeoBanner: countryCode === 'BR',
showLATAMBanner, showLATAMBanner,
@ -119,9 +139,20 @@ function formatGroupPlansDataForDash() {
*/ */
async function userSubscriptionPage(req, res) { async function userSubscriptionPage(req, res) {
const user = SessionManager.getSessionUser(req.session) const user = SessionManager.getSessionUser(req.session)
const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
req,
res,
'local-ccy-format'
)
const results = const results =
await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
user user,
req.i18n.language,
localCcyAssignment.variant === 'enabled'
? SubscriptionFormatters.formatPriceLocalized
: SubscriptionFormatters.formatPriceDefault
) )
const { const {
personalSubscription, personalSubscription,
@ -227,6 +258,12 @@ async function interstitialPaymentPage(req, res) {
geoPricingLATAMTestVariant === 'latam' && geoPricingLATAMTestVariant === 'latam' &&
['MX', 'CO', 'CL', 'PE'].includes(countryCode) ['MX', 'CO', 'CL', 'PE'].includes(countryCode)
const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
req,
res,
'local-ccy-format'
)
res.render('subscriptions/interstitial-payment', { res.render('subscriptions/interstitial-payment', {
title: 'subscribe', title: 'subscribe',
itm_content: req.query?.itm_content, itm_content: req.query?.itm_content,
@ -235,6 +272,11 @@ async function interstitialPaymentPage(req, res) {
recommendedCurrency, recommendedCurrency,
interstitialPaymentConfig, interstitialPaymentConfig,
showSkipLink, showSkipLink,
formatCurrency:
localCcyAssignment.variant === 'enabled'
? formatCurrencyLocalized
: SubscriptionHelper.formatCurrencyDefault,
showCurrencyAndPaymentMethods: localCcyAssignment.variant === 'enabled',
showInrGeoBanner: countryCode === 'IN', showInrGeoBanner: countryCode === 'IN',
showBrlGeoBanner: countryCode === 'BR', showBrlGeoBanner: countryCode === 'BR',
showLATAMBanner, showLATAMBanner,
@ -251,9 +293,18 @@ async function interstitialPaymentPage(req, res) {
*/ */
async function successfulSubscription(req, res) { async function successfulSubscription(req, res) {
const user = SessionManager.getSessionUser(req.session) const user = SessionManager.getSessionUser(req.session)
const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
req,
res,
'local-ccy-format'
)
const { personalSubscription } = const { personalSubscription } =
await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
user user,
req.i18n.language,
localCcyAssignment.variant === 'enabled'
? SubscriptionFormatters.formatPriceLocalized
: SubscriptionFormatters.formatPriceDefault
) )
const postCheckoutRedirect = req.session?.postCheckoutRedirect const postCheckoutRedirect = req.session?.postCheckoutRedirect

View file

@ -1,4 +1,5 @@
const dateformat = require('dateformat') const dateformat = require('dateformat')
const { formatCurrencyLocalized } = require('../../util/currency')
const currencySymbols = { const currencySymbols = {
EUR: '€', EUR: '€',
@ -20,38 +21,62 @@ const currencySymbols = {
PEN: 'S/', PEN: 'S/',
} }
module.exports = { function formatPriceDefault(priceInCents, currency) {
formatPrice(priceInCents, currency) { if (!currency) {
if (!currency) { currency = 'USD'
currency = 'USD' } else if (currency === 'CLP') {
} else if (currency === 'CLP') { // CLP doesn't have minor units, recurly stores the whole major unit without cents
// CLP doesn't have minor units, recurly stores the whole major unit without cents return priceInCents.toLocaleString('es-CL', {
return priceInCents.toLocaleString('es-CL', { style: 'currency',
style: 'currency', currency,
currency, minimumFractionDigits: 0,
minimumFractionDigits: 0, })
}) }
} let string = String(Math.round(priceInCents))
let string = String(Math.round(priceInCents)) if (string.length === 2) {
if (string.length === 2) { string = `0${string}`
string = `0${string}` }
} if (string.length === 1) {
if (string.length === 1) { string = `00${string}`
string = `00${string}` }
} if (string.length === 0) {
if (string.length === 0) { string = '000'
string = '000' }
} const cents = string.slice(-2)
const cents = string.slice(-2) const dollars = string.slice(0, -2)
const dollars = string.slice(0, -2) const symbol = currencySymbols[currency]
const symbol = currencySymbols[currency] return `${symbol}${dollars}.${cents}`
return `${symbol}${dollars}.${cents}` }
},
/**
formatDate(date) { * @typedef {import('@/shared/utils/currency').CurrencyCode} CurrencyCode
if (!date) { */
return null
} /**
return dateformat(date, 'mmmm dS, yyyy h:MM TT Z', true) * @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,
} }

View file

@ -9,10 +9,22 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
return oldPlan.price_in_cents > newPlan.price_in_cents 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 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 // the price is in cents, so divide by 100 to get the value
const collaboratorPrice = const collaboratorPrice =
@ -26,48 +38,44 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency) {
].price_in_cents / 100 ].price_in_cents / 100
const professionalPricePerUser = professionalPrice / INITIAL_LICENSE_SIZE 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) { switch (recommendedCurrency) {
case 'CHF': { case 'CHF': {
return { return `${recommendedCurrencySymbol} ${amount}`
price: {
collaborator: `${recommendedCurrencySymbol} ${collaboratorPrice}`,
professional: `${recommendedCurrencySymbol} ${professionalPrice}`,
},
pricePerUser: {
collaborator: `${recommendedCurrencySymbol} ${collaboratorPricePerUser}`,
professional: `${recommendedCurrencySymbol} ${professionalPricePerUser}`,
},
}
} }
case 'DKK': case 'DKK':
case 'NOK': case 'NOK':
case 'SEK': case 'SEK':
return { return `${amount} ${recommendedCurrencySymbol}`
price: { default:
collaborator: `${collaboratorPrice} ${recommendedCurrencySymbol}`, return `${recommendedCurrencySymbol}${amount}`
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}`,
},
}
}
} }
} }
module.exports = { module.exports = {
formatCurrencyDefault,
shouldPlanChangeAtTermEnd, shouldPlanChangeAtTermEnd,
generateInitialLocalizedGroupPrice, generateInitialLocalizedGroupPrice,
} }

View file

@ -66,7 +66,11 @@ async function getRedirectToHostedPage(userId, pageType) {
].join('') ].join('')
} }
async function buildUsersSubscriptionViewModel(user) { async function buildUsersSubscriptionViewModel(
user,
locale = 'en',
formatPrice = SubscriptionFormatters.formatPriceDefault
) {
let { let {
personalSubscription, personalSubscription,
memberGroupSubscriptions, memberGroupSubscriptions,
@ -312,19 +316,19 @@ async function buildUsersSubscriptionViewModel(user) {
const pendingSubscriptionTax = const pendingSubscriptionTax =
personalSubscription.recurly.taxRate * personalSubscription.recurly.taxRate *
recurlySubscription.pending_subscription.unit_amount_in_cents recurlySubscription.pending_subscription.unit_amount_in_cents
personalSubscription.recurly.displayPrice = personalSubscription.recurly.displayPrice = formatPrice(
SubscriptionFormatters.formatPrice( recurlySubscription.pending_subscription.unit_amount_in_cents +
recurlySubscription.pending_subscription.unit_amount_in_cents + pendingAddOnPrice +
pendingAddOnPrice + pendingAddOnTax +
pendingAddOnTax + pendingSubscriptionTax,
pendingSubscriptionTax, recurlySubscription.currency,
recurlySubscription.currency locale
) )
personalSubscription.recurly.currentPlanDisplayPrice = personalSubscription.recurly.currentPlanDisplayPrice = formatPrice(
SubscriptionFormatters.formatPrice( recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.unit_amount_in_cents + addOnPrice + tax, recurlySubscription.currency,
recurlySubscription.currency locale
) )
const pendingTotalLicenses = const pendingTotalLicenses =
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
personalSubscription.recurly.pendingAdditionalLicenses = personalSubscription.recurly.pendingAdditionalLicenses =
@ -332,11 +336,11 @@ async function buildUsersSubscriptionViewModel(user) {
personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
personalSubscription.pendingPlan = pendingPlan personalSubscription.pendingPlan = pendingPlan
} else { } else {
personalSubscription.recurly.displayPrice = personalSubscription.recurly.displayPrice = formatPrice(
SubscriptionFormatters.formatPrice( recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.unit_amount_in_cents + addOnPrice + tax, recurlySubscription.currency,
recurlySubscription.currency locale
) )
} }
} }

View file

@ -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,
}

View file

@ -300,6 +300,29 @@
"price_in_cents": 755000 "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": { "SEK": {
"2": { "2": {
"price_in_cents": 401600 "price_in_cents": 401600
@ -368,29 +391,6 @@
"50": { "50": {
"price_in_cents": 655000 "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": { "collaborator": {
@ -693,6 +693,29 @@
"price_in_cents": 390000 "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": { "SEK": {
"2": { "2": {
"price_in_cents": 202800 "price_in_cents": 202800
@ -761,29 +784,6 @@
"50": { "50": {
"price_in_cents": 325000 "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 "price_in_cents": 947880000
}, },
"50": { "50": {
"price_in_cents": 2170000000 "price_in_cents": 2000000000
} }
}, },
"DKK": { "DKK": {
@ -1088,6 +1088,29 @@
"price_in_cents": 1260000 "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": { "SEK": {
"2": { "2": {
"price_in_cents": 401600 "price_in_cents": 401600
@ -1156,29 +1179,6 @@
"50": { "50": {
"price_in_cents": 1095000 "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": { "collaborator": {
@ -1481,6 +1481,29 @@
"price_in_cents": 655000 "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": { "SEK": {
"2": { "2": {
"price_in_cents": 202800 "price_in_cents": 202800
@ -1549,29 +1572,6 @@
"50": { "50": {
"price_in_cents": 545000 "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
}
} }
} }
} }

View file

@ -48,6 +48,9 @@ block content
table.card.plans-v2-table.plans-v2-table-individual table.card.plans-v2-table.plans-v2-table-individual
+plans_v2_table('annual', interstitialPaymentConfig) +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 //- 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) .invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop)

View file

@ -31,20 +31,8 @@ block content
h1.text-capitalize(ng-non-bindable) #{translate('choose_your_plan')} h1.text-capitalize(ng-non-bindable) #{translate('choose_your_plan')}
include ./plans/_cards_controls_tables include ./plans/_cards_controls_tables
.row.row-spaced-large.text-centered
.col-xs-12 +currency_and_payment_methods()
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' })}
include ./plans/_university_info include ./plans/_university_info

View file

@ -8,7 +8,24 @@ mixin features_premium
li + #{translate('more').toLowerCase()} li + #{translate('more').toLowerCase()}
mixin gen_localized_price_for_plan_view(plan, view) 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) mixin plans_v2_table(period, config)
- var baseColspan = config.baseColspan || 1 - var baseColspan = config.baseColspan || 1

View file

@ -1,7 +1,12 @@
import getMeta from '../../../utils/meta' import getMeta from '../../../utils/meta'
import { swapModal } from '../../utils/swapModal' import { swapModal } from '../../utils/swapModal'
import * as eventTracking from '../../../infrastructure/event-tracking' 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() { function getFormValues() {
const modalEl = document.querySelector('[data-ol-group-plan-modal]') 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 modalEl = document.querySelector('[data-ol-group-plan-modal]')
const { planCode, size, currency, usage } = getFormValues() const { planCode, size, currency, usage } = getFormValues()
const localCcyVariant = getSplitTestVariant('local-ccy-format')
const { localizedPrice, localizedPerUserPrice } = const { localizedPrice, localizedPerUserPrice } =
createLocalizedGroupPlanPrice({ createLocalizedGroupPlanPrice({
plan: planCode, plan: planCode,
licenseSize: size, licenseSize: size,
currency, currency,
usage, usage,
formatCurrency:
localCcyVariant === 'enabled'
? formatCurrencyLocalized
: formatCurrencyDefault,
}) })
modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => { modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => {

View file

@ -1,5 +1,47 @@
import getMeta from '../../../utils/meta' 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 = { const LOCALES = {
BRL: 'pt-BR', BRL: 'pt-BR',
MXN: 'es-MX', MXN: 'es-MX',
@ -8,30 +50,12 @@ const LOCALES = {
PEN: 'es-PE', PEN: 'es-PE',
} }
// plan: 'collaborator' or 'professional' /**
// the rest of available arguments can be seen in the groupPlans value * @param {number} amount
export function createLocalizedGroupPlanPrice({ * @param {string} currency
plan, */
licenseSize, export function formatCurrencyDefault(amount, currency) {
currency,
usage,
}) {
const groupPlans = getMeta('ol-groupPlans')
const currencySymbols = getMeta('ol-currencySymbols') 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] const currencySymbol = currencySymbols[currency]
@ -42,35 +66,19 @@ export function createLocalizedGroupPlanPrice({
case 'CLP': case 'CLP':
case 'PEN': case 'PEN':
// Test using toLocaleString to format currencies for new LATAM regions // Test using toLocaleString to format currencies for new LATAM regions
return { return amount.toLocaleString(LOCALES[currency], {
localizedPrice: price.toLocaleString(LOCALES[currency], { style: 'currency',
style: 'currency', currency,
currency, minimumFractionDigits: Number.isInteger(amount) ? 0 : null,
minimumFractionDigits: 0, })
}),
localizedPerUserPrice: perUserPrice.toLocaleString(LOCALES[currency], {
style: 'currency',
currency,
minimumFractionDigits: Number.isInteger(perUserPrice) ? 0 : null,
}),
}
case 'CHF': case 'CHF':
return { return `${currencySymbol} ${amount}`
localizedPrice: `${currencySymbol} ${strPrice}`,
localizedPerUserPrice: `${currencySymbol} ${strPerUserPrice}`,
}
case 'DKK': case 'DKK':
case 'SEK': case 'SEK':
case 'NOK': case 'NOK':
return { return `${amount} ${currencySymbol}`
localizedPrice: `${strPrice} ${currencySymbol}`,
localizedPerUserPrice: `${strPerUserPrice} ${currencySymbol}`,
}
default: { default: {
return { return `${currencySymbol}${amount}`
localizedPrice: `${currencySymbol}${strPrice}`,
localizedPerUserPrice: `${currencySymbol}${strPerUserPrice}`,
}
} }
} }
} }

View file

@ -7,6 +7,7 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
CustomSubscription, CustomSubscription,
ManagedGroupSubscription, ManagedGroupSubscription,
@ -22,12 +23,15 @@ import { Institution as ManagedInstitution } from '../components/dashboard/manag
import { Publisher as ManagedPublisher } from '../components/dashboard/managed-publishers' import { Publisher as ManagedPublisher } from '../components/dashboard/managed-publishers'
import getMeta from '../../../utils/meta' import getMeta from '../../../utils/meta'
import { import {
formatCurrencyDefault,
loadDisplayPriceWithTaxPromise, loadDisplayPriceWithTaxPromise,
loadGroupDisplayPriceWithTaxPromise, loadGroupDisplayPriceWithTaxPromise,
} from '../util/recurly-pricing' } from '../util/recurly-pricing'
import { isRecurlyLoaded } from '../util/is-recurly-loaded' import { isRecurlyLoaded } from '../util/is-recurly-loaded'
import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids' import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { getSplitTestVariant } from '@/utils/splitTestUtils'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
type SubscriptionDashboardContextValue = { type SubscriptionDashboardContextValue = {
groupPlanToChangeToCode: string groupPlanToChangeToCode: string
@ -76,11 +80,17 @@ export const SubscriptionDashboardContext = createContext<
SubscriptionDashboardContextValue | undefined SubscriptionDashboardContextValue | undefined
>(undefined) >(undefined)
const getFormatCurrencies = () =>
getSplitTestVariant('local-ccy-format') === 'enabled'
? formatCurrencyLocalized
: formatCurrencyDefault
export function SubscriptionDashboardProvider({ export function SubscriptionDashboardProvider({
children, children,
}: { }: {
children: ReactNode children: ReactNode
}) { }) {
const { i18n } = useTranslation()
const [modalIdShown, setModalIdShown] = useState< const [modalIdShown, setModalIdShown] = useState<
SubscriptionDashModalIds | undefined SubscriptionDashModalIds | undefined
>() >()
@ -154,6 +164,7 @@ export function SubscriptionDashboardProvider({
plansWithoutDisplayPrice && plansWithoutDisplayPrice &&
personalSubscription?.recurly personalSubscription?.recurly
) { ) {
const formatCurrency = getFormatCurrencies()
const { currency, taxRate } = personalSubscription.recurly const { currency, taxRate } = personalSubscription.recurly
const fetchPlansDisplayPrices = async () => { const fetchPlansDisplayPrices = async () => {
for (const plan of plansWithoutDisplayPrice) { for (const plan of plansWithoutDisplayPrice) {
@ -161,10 +172,16 @@ export function SubscriptionDashboardProvider({
const priceData = await loadDisplayPriceWithTaxPromise( const priceData = await loadDisplayPriceWithTaxPromise(
plan.planCode, plan.planCode,
currency, currency,
taxRate taxRate,
i18n.language,
formatCurrency
) )
if (priceData?.totalForDisplay) { if (priceData?.totalAsNumber !== undefined) {
plan.displayPrice = priceData.totalForDisplay plan.displayPrice = formatCurrency(
priceData.totalAsNumber,
currency,
i18n.language
)
} }
} catch (error) { } catch (error) {
debugConsole.error(error) debugConsole.error(error)
@ -175,7 +192,7 @@ export function SubscriptionDashboardProvider({
} }
fetchPlansDisplayPrices().catch(debugConsole.error) fetchPlansDisplayPrices().catch(debugConsole.error)
} }
}, [personalSubscription, plansWithoutDisplayPrice]) }, [personalSubscription, plansWithoutDisplayPrice, i18n.language])
useEffect(() => { useEffect(() => {
if ( if (
@ -192,12 +209,15 @@ export function SubscriptionDashboardProvider({
setGroupPlanToChangeToPriceError(false) setGroupPlanToChangeToPriceError(false)
let priceData let priceData
try { try {
const formatCurrency = getFormatCurrencies()
priceData = await loadGroupDisplayPriceWithTaxPromise( priceData = await loadGroupDisplayPriceWithTaxPromise(
groupPlanToChangeToCode, groupPlanToChangeToCode,
currency, currency,
taxRate, taxRate,
groupPlanToChangeToSize, groupPlanToChangeToSize,
groupPlanToChangeToUsage groupPlanToChangeToUsage,
i18n.language,
formatCurrency
) )
} catch (e) { } catch (e) {
debugConsole.error(e) debugConsole.error(e)
@ -213,6 +233,7 @@ export function SubscriptionDashboardProvider({
groupPlanToChangeToSize, groupPlanToChangeToSize,
personalSubscription, personalSubscription,
groupPlanToChangeToCode, groupPlanToChangeToCode,
i18n.language,
]) ])
const updateManagedInstitution = useCallback( const updateManagedInstitution = useCallback(

View file

@ -20,17 +20,32 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
}) })
} }
function priceToWithCents(price: number) { type FormatCurrency = (
return price % 1 !== 0 ? price.toFixed(2) : price 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( export function formatPriceForDisplayData(
price: string, price: string,
taxRate: number, taxRate: number,
currencyCode: CurrencyCode currencyCode: CurrencyCode,
locale: string,
formatCurrency: FormatCurrency
): PriceForDisplayData { ): PriceForDisplayData {
const currencySymbol = currencies[currencyCode]
const totalPriceExTax = parseFloat(price) const totalPriceExTax = parseFloat(price)
let taxAmount = totalPriceExTax * taxRate let taxAmount = totalPriceExTax * taxRate
if (isNaN(taxAmount)) { if (isNaN(taxAmount)) {
@ -39,26 +54,30 @@ export function formatPriceForDisplayData(
const totalWithTax = totalPriceExTax + taxAmount const totalWithTax = totalPriceExTax + taxAmount
return { return {
totalForDisplay: `${currencySymbol}${priceToWithCents(totalWithTax)}`, totalForDisplay: formatCurrency(totalWithTax, currencyCode, locale, true),
totalAsNumber: totalWithTax, totalAsNumber: totalWithTax,
subtotal: `${currencySymbol}${totalPriceExTax.toFixed(2)}`, subtotal: formatCurrency(totalPriceExTax, currencyCode, locale),
tax: `${currencySymbol}${taxAmount.toFixed(2)}`, tax: formatCurrency(taxAmount, currencyCode, locale),
includesTax: taxAmount !== 0, includesTax: taxAmount !== 0,
} }
} }
function getPerUserDisplayPrice( function getPerUserDisplayPrice(
totalPrice: number, totalPrice: number,
currencySymbol: string, currency: CurrencyCode,
size: string size: string,
locale: string,
formatCurrency: FormatCurrency
): string { ): string {
return `${currencySymbol}${priceToWithCents(totalPrice / parseInt(size))}` return formatCurrency(totalPrice / parseInt(size), currency, locale, true)
} }
export async function loadDisplayPriceWithTaxPromise( export async function loadDisplayPriceWithTaxPromise(
planCode: string, planCode: string,
currencyCode: CurrencyCode, currencyCode: CurrencyCode,
taxRate: number taxRate: number,
locale: string,
formatCurrency: FormatCurrency
) { ) {
if (!recurly) return if (!recurly) return
@ -67,7 +86,13 @@ export async function loadDisplayPriceWithTaxPromise(
currencyCode currencyCode
)) as SubscriptionPricingState['price'] )) as SubscriptionPricingState['price']
if (price) if (price)
return formatPriceForDisplayData(price.next.total, taxRate, currencyCode) return formatPriceForDisplayData(
price.next.total,
taxRate,
currencyCode,
locale,
formatCurrency
)
} }
export async function loadGroupDisplayPriceWithTaxPromise( export async function loadGroupDisplayPriceWithTaxPromise(
@ -75,7 +100,9 @@ export async function loadGroupDisplayPriceWithTaxPromise(
currencyCode: CurrencyCode, currencyCode: CurrencyCode,
taxRate: number, taxRate: number,
size: string, size: string,
usage: string usage: string,
locale: string,
formatCurrency: FormatCurrency
) { ) {
if (!recurly) return if (!recurly) return
@ -83,15 +110,18 @@ export async function loadGroupDisplayPriceWithTaxPromise(
const price = await loadDisplayPriceWithTaxPromise( const price = await loadDisplayPriceWithTaxPromise(
planCode, planCode,
currencyCode, currencyCode,
taxRate taxRate,
locale,
formatCurrency
) )
if (price) { if (price) {
const currencySymbol = currencies[currencyCode]
price.perUserDisplayPrice = getPerUserDisplayPrice( price.perUserDisplayPrice = getPerUserDisplayPrice(
price.totalAsNumber, price.totalAsNumber,
currencySymbol, currencyCode,
size size,
locale,
formatCurrency
) )
} }

View file

@ -1,7 +1,12 @@
import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal'
import '../../../../features/plans/plans-v2-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 getMeta from '../../../../utils/meta'
import { getSplitTestVariant } from '@/utils/splitTestUtils'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
const MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT = 10 const MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT = 10
@ -21,6 +26,11 @@ export function updateMainGroupPlanPricing() {
? 'educational' ? 'educational'
: 'enterprise' : 'enterprise'
const localCcyVariant = getSplitTestVariant('local-ccy-format')
const formatCurrency =
localCcyVariant === 'enabled'
? formatCurrencyLocalized
: formatCurrencyDefault
const { const {
localizedPrice: localizedPriceProfessional, localizedPrice: localizedPriceProfessional,
localizedPerUserPrice: localizedPerUserPriceProfessional, localizedPerUserPrice: localizedPerUserPriceProfessional,
@ -29,6 +39,7 @@ export function updateMainGroupPlanPricing() {
licenseSize, licenseSize,
currency, currency,
usage, usage,
formatCurrency,
}) })
const { const {
@ -39,6 +50,7 @@ export function updateMainGroupPlanPricing() {
licenseSize, licenseSize,
currency, currency,
usage, usage,
formatCurrency,
}) })
document.querySelector( document.querySelector(

View file

@ -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',
})
}

View file

@ -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: _Command_ `node plans.js -f fileName -o outputdir` - generates three json files:
- `localizedPlanPricing.json` for `/services/web/config/settings.overrides.saas.js` - `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` - `groups.json` for `/services/web/app/templates/plans/groups.json`
The input file can be in `.csv` or `.json` format The input file can be in `.csv` or `.json` format

View file

@ -39,118 +39,38 @@ const plansMap = {
professional: 'professional', professional: 'professional',
} }
const currencies = { const currencies = [
USD: { 'AUD',
symbol: '$', 'BRL',
placement: 'before', 'CAD',
}, 'CHF',
EUR: { 'CLP',
symbol: '€', 'COP',
placement: 'before', 'DKK',
}, 'EUR',
GBP: { 'GBP',
symbol: '£', 'INR',
placement: 'before', 'MXN',
}, 'NOK',
SEK: { 'NZD',
symbol: ' kr', 'PEN',
placement: 'after', 'SEK',
}, 'SGD',
CAD: { 'USD',
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}`
}
function generatePlans(workSheetJSON) { function generatePlans(workSheetJSON) {
// localizedPlanPricing object for settings.overrides.saas.js // localizedPlanPricing object for settings.overrides.saas.js
const localizedPlanPricing = {} const localizedPlanPricing = {}
// plans object for main/plans.js // plans object for main/plans.js
const plans = {}
for (const [currency, currencyDetails] of Object.entries(currencies)) { for (const currency of currencies) {
localizedPlanPricing[currency] = { localizedPlanPricing[currency] = {
symbol: currencyDetails.symbol.trim(),
free: { free: {
monthly: buildCurrencyValue(0, currencyDetails), monthly: 0,
annual: buildCurrencyValue(0, currencyDetails), annual: 0,
}, },
} }
plans[currency] = {
symbol: currencyDetails.symbol.trim(),
}
for (const [outputKey, actualKey] of Object.entries(plansMap)) { for (const [outputKey, actualKey] of Object.entries(plansMap)) {
const monthlyPlan = workSheetJSON.find( const monthlyPlan = workSheetJSON.find(
@ -174,24 +94,17 @@ function generatePlans(workSheetJSON) {
`Missing currency "${currency}" for plan "${actualKeyAnnual}"` `Missing currency "${currency}" for plan "${actualKeyAnnual}"`
) )
const monthly = buildCurrencyValue(monthlyPlan[currency], currencyDetails) const monthly = Number(monthlyPlan[currency])
const monthlyTimesTwelve = buildCurrencyValue( const monthlyTimesTwelve = Number(monthlyPlan[currency] * 12)
monthlyPlan[currency] * 12, const annual = Number(annualPlan[currency])
currencyDetails
)
const annual = buildCurrencyValue(annualPlan[currency], currencyDetails)
localizedPlanPricing[currency] = { localizedPlanPricing[currency] = {
...localizedPlanPricing[currency], ...localizedPlanPricing[currency],
[outputKey]: { monthly, monthlyTimesTwelve, annual }, [outputKey]: { monthly, monthlyTimesTwelve, annual },
} }
plans[currency] = {
...plans[currency],
[outputKey]: { monthly, annual },
}
} }
} }
return { localizedPlanPricing, plans } return { localizedPlanPricing }
} }
function generateGroupPlans(workSheetJSON) { function generateGroupPlans(workSheetJSON) {
@ -199,25 +112,6 @@ function generateGroupPlans(workSheetJSON) {
data.plan_code.startsWith('group') 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 sizes = ['2', '3', '4', '5', '10', '20', '50']
const result = {} const result = {}
@ -275,7 +169,7 @@ function writeFile(outputFile, data) {
fs.writeFileSync(outputFile, data) fs.writeFileSync(outputFile, data)
} }
const { localizedPlanPricing, plans } = generatePlans(input) const { localizedPlanPricing } = generatePlans(input)
const groupPlans = generateGroupPlans(input) const groupPlans = generateGroupPlans(input)
if (argv.output) { if (argv.output) {
@ -291,10 +185,8 @@ if (argv.output) {
process.exit(1) process.exit(1)
} }
writeFile(`${dir}/localizedPlanPricing.json`, formatJS(localizedPlanPricing)) writeFile(`${dir}/localizedPlanPricing.json`, formatJS(localizedPlanPricing))
writeFile(`${dir}/plans.json`, formatJS(plans))
writeFile(`${dir}/groups.json`, formatJSON(groupPlans)) writeFile(`${dir}/groups.json`, formatJSON(groupPlans))
} else { } else {
console.log('PLANS', plans)
console.log('LOCALIZED', localizedPlanPricing) console.log('LOCALIZED', localizedPlanPricing)
console.log('GROUP PLANS', JSON.stringify(groupPlans, null, 2)) console.log('GROUP PLANS', JSON.stringify(groupPlans, null, 2))
} }

View file

@ -1,5 +1,6 @@
import { expect } from 'chai' import { expect } from 'chai'
import { formatPriceForDisplayData } from '../../../../../frontend/js/features/subscription/util/recurly-pricing' import { formatPriceForDisplayData } from '../../../../../frontend/js/features/subscription/util/recurly-pricing'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
describe('formatPriceForDisplayData', function () { describe('formatPriceForDisplayData', function () {
beforeEach(function () { beforeEach(function () {
@ -9,11 +10,17 @@ describe('formatPriceForDisplayData', function () {
window.metaAttributesCache = new Map() window.metaAttributesCache = new Map()
}) })
it('should handle no tax rate', function () { 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({ expect(data).to.deep.equal({
totalForDisplay: '$1000', totalForDisplay: '$1,000',
totalAsNumber: 1000, totalAsNumber: 1000,
subtotal: '$1000.00', subtotal: '$1,000.00',
tax: '$0.00', tax: '$0.00',
includesTax: false, includesTax: false,
}) })
@ -21,7 +28,13 @@ describe('formatPriceForDisplayData', function () {
}) })
it('should handle a tax rate', 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({ expect(data).to.deep.equal({
totalForDisplay: '€456', totalForDisplay: '€456',
totalAsNumber: 456, totalAsNumber: 456,
@ -32,7 +45,13 @@ describe('formatPriceForDisplayData', function () {
}) })
it('should handle total with cents', 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({ expect(data).to.deep.equal({
totalForDisplay: '€9.60', totalForDisplay: '€9.60',
totalAsNumber: 9.6, totalAsNumber: 9.6,

View file

@ -1,5 +1,6 @@
import { expect } from 'chai' import { expect } from 'chai'
import { createLocalizedGroupPlanPrice } from '../../../../frontend/js/features/plans/utils/group-plan-pricing' import { createLocalizedGroupPlanPrice } from '../../../../frontend/js/features/plans/utils/group-plan-pricing'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
describe('group-plan-pricing', function () { describe('group-plan-pricing', function () {
beforeEach(function () { beforeEach(function () {
@ -44,11 +45,12 @@ describe('group-plan-pricing', function () {
currency: 'CHF', currency: 'CHF',
licenseSize: '2', licenseSize: '2',
usage: 'enterprise', usage: 'enterprise',
formatCurrency: formatCurrencyLocalized,
}) })
expect(localizedGroupPlanPrice).to.deep.equal({ expect(localizedGroupPlanPrice).to.deep.equal({
localizedPrice: 'Fr 100', localizedPrice: 'CHF 100',
localizedPerUserPrice: 'Fr 50', localizedPerUserPrice: 'CHF 50',
}) })
}) })
}) })
@ -59,11 +61,12 @@ describe('group-plan-pricing', function () {
currency: 'DKK', currency: 'DKK',
licenseSize: '2', licenseSize: '2',
usage: 'enterprise', usage: 'enterprise',
formatCurrency: formatCurrencyLocalized,
}) })
expect(localizedGroupPlanPrice).to.deep.equal({ expect(localizedGroupPlanPrice).to.deep.equal({
localizedPrice: '200 kr', localizedPrice: 'kr 200',
localizedPerUserPrice: '100 kr', localizedPerUserPrice: 'kr 100',
}) })
}) })
}) })
@ -74,6 +77,7 @@ describe('group-plan-pricing', function () {
currency: 'USD', currency: 'USD',
licenseSize: '2', licenseSize: '2',
usage: 'enterprise', usage: 'enterprise',
formatCurrency: formatCurrencyLocalized,
}) })
expect(localizedGroupPlanPrice).to.deep.equal({ expect(localizedGroupPlanPrice).to.deep.equal({

View file

@ -14,6 +14,35 @@ function clearSettingsCache() {
settingsDeps.forEach(dep => delete require.cache[dep]) 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 () { describe('settings.defaults', function () {
it('additional text extensions can be added via config', function () { it('additional text extensions can be added via config', function () {
clearSettingsCache() clearSettingsCache()
@ -23,4 +52,35 @@ describe('settings.defaults', function () {
expect(settings.textExtensions).to.include('abc') expect(settings.textExtensions).to.include('abc')
expect(settings.textExtensions).to.include('xyz') 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
})
}) })

View file

@ -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('1000,00 $')
expect(formatUSD(9_876_543_210)).to.equal('98765432,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('1000,00 €')
expect(formatEUR(9_876_543_210)).to.equal('98765432,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('1000,00 Ft')
expect(formatHUF(9_876_543_210)).to.equal('98765432,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('1234 $')
})
it('should format thousand separators', function () {
expect(formatCLP(100_000)).to.equal('100000 $')
expect(formatCLP(9_876_543_210)).to.equal('9876543210 $')
})
it('should format negative amounts', function () {
expect(formatCLP(-1)).to.equal('-1 $')
expect(formatCLP(-1234)).to.equal('-1234 $')
})
})
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('123456790 $')
expect(format('JPY')(amount)).to.equal('123456790 ¥')
expect(format('KRW')(amount)).to.equal('123456790 ₩')
expect(format('VND')(amount)).to.equal('123456790 ₫')
// other currencies
expect(format('AUD')(amount)).to.equal('1234567,90 $')
expect(format('BRL')(amount)).to.equal('1234567,90 R$')
expect(format('CAD')(amount)).to.equal('1234567,90 $')
expect(format('CHF')(amount)).to.equal('1234567,90 CHF')
expect(format('CNY')(amount)).to.equal('1234567,90 ¥')
expect(format('COP')(amount)).to.equal('1234567,90 $')
expect(format('EUR')(amount)).to.equal('1234567,90 €')
expect(format('GBP')(amount)).to.equal('1234567,90 £')
expect(format('USD')(amount)).to.equal('1234567,90 $')
})
})
})
})

View file

@ -1,5 +1,6 @@
const SandboxedModule = require('sandboxed-module') const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai') const { expect } = require('chai')
const { formatCurrencyLocalized } = require('../../../../app/src/util/currency')
const modulePath = const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionHelper' '../../../../app/src/Features/Subscription/SubscriptionHelper'
@ -151,16 +152,20 @@ describe('SubscriptionHelper', function () {
describe('CHF currency', function () { describe('CHF currency', function () {
it('should return the correct localized price for every plan', function () { it('should return the correct localized price for every plan', function () {
const localizedPrice = const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice('CHF') this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'CHF',
'fr',
formatCurrencyLocalized
)
expect(localizedPrice).to.deep.equal({ expect(localizedPrice).to.deep.equal({
price: { price: {
collaborator: 'Fr 10', collaborator: '10 CHF',
professional: 'Fr 100', professional: '100 CHF',
}, },
pricePerUser: { pricePerUser: {
collaborator: 'Fr 5', collaborator: '5 CHF',
professional: 'Fr 50', professional: '50 CHF',
}, },
}) })
}) })
@ -169,16 +174,20 @@ describe('SubscriptionHelper', function () {
describe('DKK currency', function () { describe('DKK currency', function () {
it('should return the correct localized price for every plan', function () { it('should return the correct localized price for every plan', function () {
const localizedPrice = const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice('DKK') this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'DKK',
'da',
formatCurrencyLocalized
)
expect(localizedPrice).to.deep.equal({ expect(localizedPrice).to.deep.equal({
price: { price: {
collaborator: '20 kr', collaborator: '20 kr.',
professional: '200 kr', professional: '200 kr.',
}, },
pricePerUser: { pricePerUser: {
collaborator: '10 kr', collaborator: '10 kr.',
professional: '100 kr', professional: '100 kr.',
}, },
}) })
}) })
@ -187,16 +196,20 @@ describe('SubscriptionHelper', function () {
describe('SEK currency', function () { describe('SEK currency', function () {
it('should return the correct localized price for every plan', function () { it('should return the correct localized price for every plan', function () {
const localizedPrice = const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice('SEK') this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'SEK',
'sv',
formatCurrencyLocalized
)
expect(localizedPrice).to.deep.equal({ expect(localizedPrice).to.deep.equal({
price: { price: {
collaborator: '30 kr', collaborator: '30 kr',
professional: '300 kr', professional: '300 kr',
}, },
pricePerUser: { pricePerUser: {
collaborator: '15 kr', collaborator: '15 kr',
professional: '150 kr', professional: '150 kr',
}, },
}) })
}) })
@ -205,16 +218,22 @@ describe('SubscriptionHelper', function () {
describe('NOK currency', function () { describe('NOK currency', function () {
it('should return the correct localized price for every plan', function () { it('should return the correct localized price for every plan', function () {
const localizedPrice = 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({ expect(localizedPrice).to.deep.equal({
price: { price: {
collaborator: '40 kr', collaborator: 'kr 40',
professional: '400 kr', professional: 'kr 400',
}, },
pricePerUser: { pricePerUser: {
collaborator: '20 kr', collaborator: 'kr 20',
professional: '200 kr', professional: 'kr 200',
}, },
}) })
}) })
@ -223,7 +242,11 @@ describe('SubscriptionHelper', function () {
describe('other supported currencies', function () { describe('other supported currencies', function () {
it('should return the correct localized price for every plan', function () { it('should return the correct localized price for every plan', function () {
const localizedPrice = const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice('USD') this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'USD',
'en',
formatCurrencyLocalized
)
expect(localizedPrice).to.deep.equal({ expect(localizedPrice).to.deep.equal({
price: { price: {