[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 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

View file

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

View file

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

View file

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

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

View file

@ -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)

View file

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

View file

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

View file

@ -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 => {

View file

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

View file

@ -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(

View file

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

View file

@ -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(

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:
- `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

View file

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

View file

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

View file

@ -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({

View file

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

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 { 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: {