Procházet zdrojové kódy

[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
Antoine Clausse před 2 roky
rodič
revize
b2ef7a935f
22 změnil soubory, kde provedl 975 přidání a 427 odebrání
  1. 54 3
      services/web/app/src/Features/Subscription/SubscriptionController.js
  2. 58 33
      services/web/app/src/Features/Subscription/SubscriptionFormatters.js
  3. 43 35
      services/web/app/src/Features/Subscription/SubscriptionHelper.js
  4. 23 19
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  5. 34 0
      services/web/app/src/util/currency.js
  6. 93 93
      services/web/app/templates/plans/groups.json
  7. 3 0
      services/web/app/views/subscriptions/interstitial-payment.pug
  8. 2 14
      services/web/app/views/subscriptions/plans.pug
  9. 18 1
      services/web/app/views/subscriptions/plans/_mixins.pug
  10. 12 1
      services/web/frontend/js/features/plans/group-plan-modal/index.js
  11. 46 38
      services/web/frontend/js/features/plans/utils/group-plan-pricing.js
  12. 26 5
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  13. 48 18
      services/web/frontend/js/features/subscription/util/recurly-pricing.ts
  14. 13 1
      services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js
  15. 39 0
      services/web/frontend/js/shared/utils/currency.ts
  16. 0 1
      services/web/scripts/plan-prices/README.md
  17. 27 135
      services/web/scripts/plan-prices/plans.js
  18. 24 5
      services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts
  19. 8 4
      services/web/test/frontend/shared/utils/group-plan-pricing.test.js
  20. 60 0
      services/web/test/unit/src/Settings/SettingsTests.js
  21. 300 0
      services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js
  22. 44 21
      services/web/test/unit/src/Subscription/SubscriptionHelperTests.js

+ 54 - 3
services/web/app/src/Features/Subscription/SubscriptionController.js

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

+ 58 - 33
services/web/app/src/Features/Subscription/SubscriptionFormatters.js

@@ -1,4 +1,5 @@
 const dateformat = require('dateformat')
+const { formatCurrencyLocalized } = require('../../util/currency')
 
 const currencySymbols = {
   EUR: '€',
@@ -20,38 +21,62 @@ const currencySymbols = {
   PEN: 'S/',
 }
 
+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 = {
-  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)
-  },
+  formatPriceDefault,
+  formatPriceLocalized,
+  formatDate,
 }

+ 43 - 35
services/web/app/src/Features/Subscription/SubscriptionHelper.js

@@ -9,10 +9,22 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
   return oldPlan.price_in_cents > newPlan.price_in_cents
 }
 
-function generateInitialLocalizedGroupPrice(recommendedCurrency) {
+/**
+ * @typedef {import('../../../../frontend/js/shared/utils/currency').CurrencyCode} CurrencyCode
+ */
+
+/**
+ * @param {CurrencyCode} recommendedCurrency
+ * @param {string} locale
+ * @param {(amount: number, currency: CurrencyCode, locale: string, stripIfInteger: boolean) => string} formatCurrency
+ * @returns {{ price: { collaborator: string, professional: string }, pricePerUser: { collaborator: string, professional: string } }} - localized group price
+ */
+function generateInitialLocalizedGroupPrice(
+  recommendedCurrency,
+  locale,
+  formatCurrency
+) {
   const INITIAL_LICENSE_SIZE = 2
-  const currencySymbols = Settings.groupPlanModalOptions.currencySymbols
-  const recommendedCurrencySymbol = currencySymbols[recommendedCurrency]
 
   // the price is in cents, so divide by 100 to get the value
   const collaboratorPrice =
@@ -26,48 +38,44 @@ function generateInitialLocalizedGroupPrice(recommendedCurrency) {
     ].price_in_cents / 100
   const professionalPricePerUser = professionalPrice / INITIAL_LICENSE_SIZE
 
+  /**
+   * @param {number} price
+   * @returns {string}
+   */
+  const formatPrice = price =>
+    formatCurrency(price, recommendedCurrency, locale, true)
+
+  return {
+    price: {
+      collaborator: formatPrice(collaboratorPrice),
+      professional: formatPrice(professionalPrice),
+    },
+    pricePerUser: {
+      collaborator: formatPrice(collaboratorPricePerUser),
+      professional: formatPrice(professionalPricePerUser),
+    },
+  }
+}
+
+function formatCurrencyDefault(amount, recommendedCurrency) {
+  const currencySymbols = Settings.groupPlanModalOptions.currencySymbols
+  const recommendedCurrencySymbol = currencySymbols[recommendedCurrency]
+
   switch (recommendedCurrency) {
     case 'CHF': {
-      return {
-        price: {
-          collaborator: `${recommendedCurrencySymbol} ${collaboratorPrice}`,
-          professional: `${recommendedCurrencySymbol} ${professionalPrice}`,
-        },
-        pricePerUser: {
-          collaborator: `${recommendedCurrencySymbol} ${collaboratorPricePerUser}`,
-          professional: `${recommendedCurrencySymbol} ${professionalPricePerUser}`,
-        },
-      }
+      return `${recommendedCurrencySymbol} ${amount}`
     }
     case 'DKK':
     case 'NOK':
     case 'SEK':
-      return {
-        price: {
-          collaborator: `${collaboratorPrice} ${recommendedCurrencySymbol}`,
-          professional: `${professionalPrice} ${recommendedCurrencySymbol}`,
-        },
-        pricePerUser: {
-          collaborator: `${collaboratorPricePerUser} ${recommendedCurrencySymbol}`,
-          professional: `${professionalPricePerUser} ${recommendedCurrencySymbol}`,
-        },
-      }
-    default: {
-      return {
-        price: {
-          collaborator: `${recommendedCurrencySymbol}${collaboratorPrice}`,
-          professional: `${recommendedCurrencySymbol}${professionalPrice}`,
-        },
-        pricePerUser: {
-          collaborator: `${recommendedCurrencySymbol}${collaboratorPricePerUser}`,
-          professional: `${recommendedCurrencySymbol}${professionalPricePerUser}`,
-        },
-      }
-    }
+      return `${amount} ${recommendedCurrencySymbol}`
+    default:
+      return `${recommendedCurrencySymbol}${amount}`
   }
 }
 
 module.exports = {
+  formatCurrencyDefault,
   shouldPlanChangeAtTermEnd,
   generateInitialLocalizedGroupPrice,
 }

+ 23 - 19
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

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

+ 34 - 0
services/web/app/src/util/currency.js

@@ -0,0 +1,34 @@
+/**
+ * This file is duplicated from services/web/frontend/js/shared/utils/currency.ts
+ */
+
+/**
+ * @typedef {import('@/shared/utils/currency').CurrencyCode} CurrencyCode
+ */
+
+/**
+ * @param {number} amount
+ * @param {CurrencyCode} currency
+ * @param {string} locale
+ * @param {boolean} stripIfInteger
+ * @returns {string}
+ */
+function formatCurrencyLocalized(amount, currency, locale, stripIfInteger) {
+  if (stripIfInteger && Number.isInteger(amount)) {
+    return amount.toLocaleString(locale, {
+      style: 'currency',
+      currency,
+      minimumFractionDigits: 0,
+      currencyDisplay: 'narrowSymbol',
+    })
+  }
+  return amount.toLocaleString(locale, {
+    style: 'currency',
+    currency,
+    currencyDisplay: 'narrowSymbol',
+  })
+}
+
+module.exports = {
+  formatCurrencyLocalized,
+}

+ 93 - 93
services/web/app/templates/plans/groups.json

@@ -300,6 +300,29 @@
           "price_in_cents": 755000
         }
       },
+      "PEN": {
+        "2": {
+          "price_in_cents": 134200
+        },
+        "3": {
+          "price_in_cents": 201300
+        },
+        "4": {
+          "price_in_cents": 268400
+        },
+        "5": {
+          "price_in_cents": 335500
+        },
+        "10": {
+          "price_in_cents": 374000
+        },
+        "20": {
+          "price_in_cents": 690000
+        },
+        "50": {
+          "price_in_cents": 1580000
+        }
+      },
       "SEK": {
         "2": {
           "price_in_cents": 401600
@@ -368,29 +391,6 @@
         "50": {
           "price_in_cents": 655000
         }
-      },
-      "PEN": {
-        "2": {
-          "price_in_cents": 134200
-        },
-        "3": {
-          "price_in_cents": 201300
-        },
-        "4": {
-          "price_in_cents": 268400
-        },
-        "5": {
-          "price_in_cents": 335500
-        },
-        "10": {
-          "price_in_cents": 374000
-        },
-        "20": {
-          "price_in_cents": 690000
-        },
-        "50": {
-          "price_in_cents": 1580000
-        }
       }
     },
     "collaborator": {
@@ -693,6 +693,29 @@
           "price_in_cents": 390000
         }
       },
+      "PEN": {
+        "2": {
+          "price_in_cents": 64200
+        },
+        "3": {
+          "price_in_cents": 96300
+        },
+        "4": {
+          "price_in_cents": 128400
+        },
+        "5": {
+          "price_in_cents": 160500
+        },
+        "10": {
+          "price_in_cents": 179000
+        },
+        "20": {
+          "price_in_cents": 330000
+        },
+        "50": {
+          "price_in_cents": 755000
+        }
+      },
       "SEK": {
         "2": {
           "price_in_cents": 202800
@@ -761,29 +784,6 @@
         "50": {
           "price_in_cents": 325000
         }
-      },
-      "PEN": {
-        "2": {
-          "price_in_cents": 64200
-        },
-        "3": {
-          "price_in_cents": 96300
-        },
-        "4": {
-          "price_in_cents": 128400
-        },
-        "5": {
-          "price_in_cents": 160500
-        },
-        "10": {
-          "price_in_cents": 179000
-        },
-        "20": {
-          "price_in_cents": 330000
-        },
-        "50": {
-          "price_in_cents": 755000
-        }
       }
     }
   },
@@ -924,7 +924,7 @@
           "price_in_cents": 947880000
         },
         "50": {
-          "price_in_cents": 2170000000
+          "price_in_cents": 2000000000
         }
       },
       "DKK": {
@@ -1088,6 +1088,29 @@
           "price_in_cents": 1260000
         }
       },
+      "PEN": {
+        "2": {
+          "price_in_cents": 134200
+        },
+        "3": {
+          "price_in_cents": 201300
+        },
+        "4": {
+          "price_in_cents": 268400
+        },
+        "5": {
+          "price_in_cents": 335500
+        },
+        "10": {
+          "price_in_cents": 623000
+        },
+        "20": {
+          "price_in_cents": 1150000
+        },
+        "50": {
+          "price_in_cents": 2635000
+        }
+      },
       "SEK": {
         "2": {
           "price_in_cents": 401600
@@ -1156,29 +1179,6 @@
         "50": {
           "price_in_cents": 1095000
         }
-      },
-      "PEN": {
-        "2": {
-          "price_in_cents": 134200
-        },
-        "3": {
-          "price_in_cents": 201300
-        },
-        "4": {
-          "price_in_cents": 268400
-        },
-        "5": {
-          "price_in_cents": 335500
-        },
-        "10": {
-          "price_in_cents": 623000
-        },
-        "20": {
-          "price_in_cents": 1150000
-        },
-        "50": {
-          "price_in_cents": 2635000
-        }
       }
     },
     "collaborator": {
@@ -1481,6 +1481,29 @@
           "price_in_cents": 655000
         }
       },
+      "PEN": {
+        "2": {
+          "price_in_cents": 64200
+        },
+        "3": {
+          "price_in_cents": 96300
+        },
+        "4": {
+          "price_in_cents": 128400
+        },
+        "5": {
+          "price_in_cents": 160500
+        },
+        "10": {
+          "price_in_cents": 298000
+        },
+        "20": {
+          "price_in_cents": 550000
+        },
+        "50": {
+          "price_in_cents": 1260000
+        }
+      },
       "SEK": {
         "2": {
           "price_in_cents": 202800
@@ -1549,29 +1572,6 @@
         "50": {
           "price_in_cents": 545000
         }
-      },
-      "PEN": {
-        "2": {
-          "price_in_cents": 64200
-        },
-        "3": {
-          "price_in_cents": 96300
-        },
-        "4": {
-          "price_in_cents": 128400
-        },
-        "5": {
-          "price_in_cents": 160500
-        },
-        "10": {
-          "price_in_cents": 298000
-        },
-        "20": {
-          "price_in_cents": 550000
-        },
-        "50": {
-          "price_in_cents": 1260000
-        }
       }
     }
   }

+ 3 - 0
services/web/app/views/subscriptions/interstitial-payment.pug

@@ -48,6 +48,9 @@ block content
 								table.card.plans-v2-table.plans-v2-table-individual
 									+plans_v2_table('annual', interstitialPaymentConfig)
 
+					if (showCurrencyAndPaymentMethods)
+						+currency_and_payment_methods()
+
 					//- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div
 					.invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop)
 

+ 2 - 14
services/web/app/views/subscriptions/plans.pug

@@ -31,20 +31,8 @@ block content
 								h1.text-capitalize(ng-non-bindable) #{translate('choose_your_plan')}
 
 					include ./plans/_cards_controls_tables
-					.row.row-spaced-large.text-centered
-						.col-xs-12
-							p.text-centered
-								strong #{translate("all_prices_displayed_are_in_currency", {recommendedCurrency})}
-								|  
-								span #{translate("subject_to_additional_vat")}
-							i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")  
-							span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })}
-							i.fa.fa-cc-visa.fa-2x(aria-hidden="true")  
-							span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })}
-							i.fa.fa-cc-amex.fa-2x(aria-hidden="true")  
-							span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })}
-							i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")  
-							span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })}
+					
+					+currency_and_payment_methods()
 
 					include ./plans/_university_info
 

+ 18 - 1
services/web/app/views/subscriptions/plans/_mixins.pug

@@ -8,7 +8,24 @@ mixin features_premium
 	li + #{translate('more').toLowerCase()}
 
 mixin gen_localized_price_for_plan_view(plan, view)
-	span #{settings.localizedPlanPricing[recommendedCurrency][plan][view]}
+	span #{formatCurrency(settings.localizedPlanPricing[recommendedCurrency][plan][view], recommendedCurrency, language, true)}
+
+mixin currency_and_payment_methods()
+	.row.row-spaced-large.text-centered
+		.col-xs-12
+			p.text-centered
+				strong #{translate("all_prices_displayed_are_in_currency", { recommendedCurrency })}
+				|  
+				span #{translate("subject_to_additional_vat")}
+			i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")  
+			span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })}
+			i.fa.fa-cc-visa.fa-2x(aria-hidden="true")  
+			span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })}
+			i.fa.fa-cc-amex.fa-2x(aria-hidden="true")  
+			span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })}
+			i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")  
+			span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })}
+
 
 mixin plans_v2_table(period, config)
 	- var baseColspan = config.baseColspan || 1

+ 12 - 1
services/web/frontend/js/features/plans/group-plan-modal/index.js

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

+ 46 - 38
services/web/frontend/js/features/plans/utils/group-plan-pricing.js

@@ -1,37 +1,61 @@
 import getMeta from '../../../utils/meta'
 
-const LOCALES = {
-  BRL: 'pt-BR',
-  MXN: 'es-MX',
-  COP: 'es-CO',
-  CLP: 'es-CL',
-  PEN: 'es-PE',
-}
+/**
+ * @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 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 = ''
+  /**
+   * @param {number} price
+   * @returns {string}
+   */
+  const formatPrice = price => formatCurrency(price, currency, locale, true)
 
-  if (Number.isInteger(perUserPrice)) {
-    strPerUserPrice = String(perUserPrice)
-  } else {
-    strPerUserPrice = perUserPrice.toFixed(2)
+  return {
+    localizedPrice: formatPrice(price),
+    localizedPerUserPrice: formatPrice(perUserPrice),
   }
+}
+
+const LOCALES = {
+  BRL: 'pt-BR',
+  MXN: 'es-MX',
+  COP: 'es-CO',
+  CLP: 'es-CL',
+  PEN: 'es-PE',
+}
+
+/**
+ * @param {number} amount
+ * @param {string} currency
+ */
+export function formatCurrencyDefault(amount, currency) {
+  const currencySymbols = getMeta('ol-currencySymbols')
 
   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}`
     }
   }
 }

+ 26 - 5
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

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

+ 48 - 18
services/web/frontend/js/features/subscription/util/recurly-pricing.ts

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

+ 13 - 1
services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js

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

+ 39 - 0
services/web/frontend/js/shared/utils/currency.ts

@@ -0,0 +1,39 @@
+export type CurrencyCode =
+  | 'AUD'
+  | 'BRL'
+  | 'CAD'
+  | 'CHF'
+  | 'CLP'
+  | 'COP'
+  | 'DKK'
+  | 'EUR'
+  | 'GBP'
+  | 'INR'
+  | 'MXN'
+  | 'NOK'
+  | 'NZD'
+  | 'PEN'
+  | 'SEK'
+  | 'SGD'
+  | 'USD'
+
+export function formatCurrencyLocalized(
+  amount: number,
+  currency: CurrencyCode,
+  locale: string,
+  stripIfInteger = false
+): string {
+  if (stripIfInteger && Number.isInteger(amount)) {
+    return amount.toLocaleString(locale, {
+      style: 'currency',
+      currency,
+      minimumFractionDigits: 0,
+      currencyDisplay: 'narrowSymbol',
+    })
+  }
+  return amount.toLocaleString(locale, {
+    style: 'currency',
+    currency,
+    currencyDisplay: 'narrowSymbol',
+  })
+}

+ 0 - 1
services/web/scripts/plan-prices/README.md

@@ -9,7 +9,6 @@ The scripts will put the output results into the `output` folder.
 _Command_ `node plans.js -f fileName -o outputdir` - generates three json files:
 
 - `localizedPlanPricing.json` for `/services/web/config/settings.overrides.saas.js`
-- `plans.json` for `/services/web/frontend/js/main/plans.js`
 - `groups.json` for `/services/web/app/templates/plans/groups.json`
 
 The input file can be in `.csv` or `.json` format

+ 27 - 135
services/web/scripts/plan-prices/plans.js

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

+ 24 - 5
services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts

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

+ 8 - 4
services/web/test/frontend/shared/utils/group-plan-pricing.test.js

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

+ 60 - 0
services/web/test/unit/src/Settings/SettingsTests.js

@@ -14,6 +14,35 @@ function clearSettingsCache() {
   settingsDeps.forEach(dep => delete require.cache[dep])
 }
 
+/**
+ * @param {any} value
+ * @returns {string} A string representation of the structure of the value
+ */
+function serializeTypes(value) {
+  if (typeof value === 'object') {
+    const keys = Object.keys(value).sort()
+    const types = keys.reduce((acc, key) => {
+      acc[key] = serializeTypes(value[key])
+      return acc
+    }, {})
+    return JSON.stringify(types)
+  }
+  if (Array.isArray(value)) {
+    return JSON.stringify(value.map(serializeTypes))
+  }
+  return typeof value
+}
+
+/**
+ * @param {any[]} objects
+ * @returns {boolean} Whether all objects have the same structure
+ */
+function haveSameStructure(objects) {
+  if (!objects.length) return true
+  const referenceStructure = serializeTypes(objects[0])
+  return objects.every(obj => serializeTypes(obj) === referenceStructure)
+}
+
 describe('settings.defaults', function () {
   it('additional text extensions can be added via config', function () {
     clearSettingsCache()
@@ -23,4 +52,35 @@ describe('settings.defaults', function () {
     expect(settings.textExtensions).to.include('abc')
     expect(settings.textExtensions).to.include('xyz')
   })
+
+  it('generates pricings with same structures', function () {
+    const settingsOverridesSaas = require('../../../../config/settings.overrides.saas.js')
+    const { localizedPlanPricing } = settingsOverridesSaas
+
+    const pricingCurrencies = Object.keys(localizedPlanPricing)
+    expect(pricingCurrencies.sort()).to.eql([
+      'AUD',
+      'BRL',
+      'CAD',
+      'CHF',
+      'CLP',
+      'COP',
+      'DKK',
+      'EUR',
+      'GBP',
+      'INR',
+      'MXN',
+      'NOK',
+      'NZD',
+      'PEN',
+      'SEK',
+      'SGD',
+      'USD',
+    ])
+
+    const pricings = pricingCurrencies.map(
+      currency => localizedPlanPricing[currency]
+    )
+    expect(haveSameStructure(pricings)).to.be.true
+  })
 })

+ 300 - 0
services/web/test/unit/src/Subscription/SubscriptionFormattersTests.js

@@ -0,0 +1,300 @@
+const chai = require('chai')
+const SubscriptionFormatters = require('../../../../app/src/Features/Subscription/SubscriptionFormatters')
+
+const { expect } = chai
+
+/*
+  Users can select any language we support, regardless of the country where they are located.
+  Which mean that any combination of "supported language"-"supported currency" can be displayed
+  on the user's screen.
+
+  Users located in the USA visiting https://fr.overleaf.com/user/subscription/plans
+  should see amounts in USD (because of their IP address),
+  but with French text, number formatting and currency formats (because of language choice).
+  (e.g. 1 000,00 $)
+
+  Users located in the France visiting https://www.overleaf.com/user/subscription/plans
+  should see amounts in EUR (because of their IP address),
+  but with English text, number formatting and currency formats (because of language choice).
+  (e.g. €1,000.00)
+ */
+
+describe('SubscriptionFormatters.formatPrice', function () {
+  describe('en', function () {
+    const format = currency => priceInCents =>
+      SubscriptionFormatters.formatPriceLocalized(priceInCents, currency)
+
+    describe('USD', function () {
+      const formatUSD = format('USD')
+
+      it('should format basic amounts', function () {
+        expect(formatUSD(0)).to.equal('$0.00')
+        expect(formatUSD(1234)).to.equal('$12.34')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatUSD(100_000)).to.equal('$1,000.00')
+        expect(formatUSD(9_876_543_210)).to.equal('$98,765,432.10')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatUSD(-1)).to.equal('-$0.01')
+        expect(formatUSD(-1234)).to.equal('-$12.34')
+      })
+    })
+
+    describe('EUR', function () {
+      const formatEUR = format('EUR')
+
+      it('should format basic amounts', function () {
+        expect(formatEUR(0)).to.equal('€0.00')
+        expect(formatEUR(1234)).to.equal('€12.34')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatEUR(100_000)).to.equal('€1,000.00')
+        expect(formatEUR(9_876_543_210)).to.equal('€98,765,432.10')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatEUR(-1)).to.equal('-€0.01')
+        expect(formatEUR(-1234)).to.equal('-€12.34')
+      })
+    })
+
+    describe('HUF', function () {
+      const formatHUF = format('HUF')
+
+      it('should format basic amounts', function () {
+        expect(formatHUF(0)).to.equal('Ft 0.00')
+        expect(formatHUF(1234)).to.equal('Ft 12.34')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatHUF(100_000)).to.equal('Ft 1,000.00')
+        expect(formatHUF(9_876_543_210)).to.equal('Ft 98,765,432.10')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatHUF(-1)).to.equal('-Ft 0.01')
+        expect(formatHUF(-1234)).to.equal('-Ft 12.34')
+      })
+    })
+
+    describe('CLP', function () {
+      const formatCLP = format('CLP')
+
+      it('should format basic amounts', function () {
+        expect(formatCLP(0)).to.equal('$0')
+        expect(formatCLP(1234)).to.equal('$1,234')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatCLP(100_000)).to.equal('$100,000')
+        expect(formatCLP(9_876_543_210)).to.equal('$9,876,543,210')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatCLP(-1)).to.equal('-$1')
+        expect(formatCLP(-1234)).to.equal('-$1,234')
+      })
+    })
+
+    describe('all currencies', function () {
+      it('should format 100 "minimal atomic units"', function () {
+        const amount = 100
+
+        // "no cents currencies"
+        expect(format('CLP')(amount)).to.equal('$100')
+        expect(format('JPY')(amount)).to.equal('¥100')
+        expect(format('KRW')(amount)).to.equal('₩100')
+        expect(format('VND')(amount)).to.equal('₫100')
+
+        // other currencies
+        expect(format('AUD')(amount)).to.equal('$1.00')
+        expect(format('BRL')(amount)).to.equal('R$1.00')
+        expect(format('CAD')(amount)).to.equal('$1.00')
+        expect(format('CHF')(amount)).to.equal('CHF 1.00')
+        expect(format('CNY')(amount)).to.equal('¥1.00')
+        expect(format('COP')(amount)).to.equal('$1.00')
+        expect(format('DKK')(amount)).to.equal('kr 1.00')
+        expect(format('EUR')(amount)).to.equal('€1.00')
+        expect(format('GBP')(amount)).to.equal('£1.00')
+        expect(format('HUF')(amount)).to.equal('Ft 1.00')
+        expect(format('IDR')(amount)).to.equal('Rp 1.00')
+        expect(format('INR')(amount)).to.equal('₹1.00')
+        expect(format('MXN')(amount)).to.equal('$1.00')
+        expect(format('MYR')(amount)).to.equal('RM 1.00')
+        expect(format('NOK')(amount)).to.equal('kr 1.00')
+        expect(format('NZD')(amount)).to.equal('$1.00')
+        expect(format('PEN')(amount)).to.equal('PEN 1.00')
+        expect(format('PHP')(amount)).to.equal('₱1.00')
+        expect(format('SEK')(amount)).to.equal('kr 1.00')
+        expect(format('SGD')(amount)).to.equal('$1.00')
+        expect(format('THB')(amount)).to.equal('฿1.00')
+        expect(format('USD')(amount)).to.equal('$1.00')
+      })
+
+      it('should format 123_456_789.987_654 "minimal atomic units"', function () {
+        const amount = 123_456_789.987_654
+
+        // "no cents currencies"
+        expect(format('CLP')(amount)).to.equal('$123,456,790')
+        expect(format('JPY')(amount)).to.equal('¥123,456,790')
+        expect(format('KRW')(amount)).to.equal('₩123,456,790')
+        expect(format('VND')(amount)).to.equal('₫123,456,790')
+
+        // other currencies
+        expect(format('AUD')(amount)).to.equal('$1,234,567.90')
+        expect(format('BRL')(amount)).to.equal('R$1,234,567.90')
+        expect(format('CAD')(amount)).to.equal('$1,234,567.90')
+        expect(format('CHF')(amount)).to.equal('CHF 1,234,567.90')
+        expect(format('CNY')(amount)).to.equal('¥1,234,567.90')
+        expect(format('COP')(amount)).to.equal('$1,234,567.90')
+        expect(format('DKK')(amount)).to.equal('kr 1,234,567.90')
+        expect(format('EUR')(amount)).to.equal('€1,234,567.90')
+        expect(format('GBP')(amount)).to.equal('£1,234,567.90')
+        expect(format('HUF')(amount)).to.equal('Ft 1,234,567.90')
+        expect(format('IDR')(amount)).to.equal('Rp 1,234,567.90')
+        expect(format('INR')(amount)).to.equal('₹1,234,567.90')
+        expect(format('MXN')(amount)).to.equal('$1,234,567.90')
+        expect(format('MYR')(amount)).to.equal('RM 1,234,567.90')
+        expect(format('NOK')(amount)).to.equal('kr 1,234,567.90')
+        expect(format('NZD')(amount)).to.equal('$1,234,567.90')
+        expect(format('PEN')(amount)).to.equal('PEN 1,234,567.90')
+        expect(format('PHP')(amount)).to.equal('₱1,234,567.90')
+        expect(format('SEK')(amount)).to.equal('kr 1,234,567.90')
+        expect(format('SGD')(amount)).to.equal('$1,234,567.90')
+        expect(format('THB')(amount)).to.equal('฿1,234,567.90')
+        expect(format('USD')(amount)).to.equal('$1,234,567.90')
+      })
+    })
+  })
+
+  describe('fr', function () {
+    const format = currency => priceInCents =>
+      SubscriptionFormatters.formatPriceLocalized(priceInCents, currency, 'fr')
+
+    describe('USD', function () {
+      const formatUSD = format('USD')
+
+      it('should format basic amounts', function () {
+        expect(formatUSD(0)).to.equal('0,00 $')
+        expect(formatUSD(1234)).to.equal('12,34 $')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatUSD(100_000)).to.equal('1 000,00 $')
+        expect(formatUSD(9_876_543_210)).to.equal('98 765 432,10 $')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatUSD(-1)).to.equal('-0,01 $')
+        expect(formatUSD(-1234)).to.equal('-12,34 $')
+      })
+    })
+
+    describe('EUR', function () {
+      const formatEUR = format('EUR')
+
+      it('should format basic amounts', function () {
+        expect(formatEUR(0)).to.equal('0,00 €')
+        expect(formatEUR(1234)).to.equal('12,34 €')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatEUR(100_000)).to.equal('1 000,00 €')
+        expect(formatEUR(9_876_543_210)).to.equal('98 765 432,10 €')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatEUR(-1)).to.equal('-0,01 €')
+        expect(formatEUR(-1234)).to.equal('-12,34 €')
+      })
+    })
+
+    describe('HUF', function () {
+      const formatHUF = format('HUF')
+
+      it('should format basic amounts', function () {
+        expect(formatHUF(0)).to.equal('0,00 Ft')
+        expect(formatHUF(1234)).to.equal('12,34 Ft')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatHUF(100_000)).to.equal('1 000,00 Ft')
+        expect(formatHUF(9_876_543_210)).to.equal('98 765 432,10 Ft')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatHUF(-1)).to.equal('-0,01 Ft')
+        expect(formatHUF(-1234)).to.equal('-12,34 Ft')
+      })
+    })
+
+    describe('CLP', function () {
+      const formatCLP = format('CLP')
+
+      it('should format basic amounts', function () {
+        expect(formatCLP(0)).to.equal('0 $')
+        expect(formatCLP(1234)).to.equal('1 234 $')
+      })
+
+      it('should format thousand separators', function () {
+        expect(formatCLP(100_000)).to.equal('100 000 $')
+        expect(formatCLP(9_876_543_210)).to.equal('9 876 543 210 $')
+      })
+
+      it('should format negative amounts', function () {
+        expect(formatCLP(-1)).to.equal('-1 $')
+        expect(formatCLP(-1234)).to.equal('-1 234 $')
+      })
+    })
+
+    describe('all currencies', function () {
+      it('should format 100 "minimal atomic units"', function () {
+        const amount = 100
+
+        // "no cents currencies"
+        expect(format('CLP')(amount)).to.equal('100 $')
+        expect(format('JPY')(amount)).to.equal('100 ¥')
+        expect(format('KRW')(amount)).to.equal('100 ₩')
+        expect(format('VND')(amount)).to.equal('100 ₫')
+
+        // other currencies
+        expect(format('AUD')(amount)).to.equal('1,00 $')
+        expect(format('BRL')(amount)).to.equal('1,00 R$')
+        expect(format('CAD')(amount)).to.equal('1,00 $')
+        expect(format('CHF')(amount)).to.equal('1,00 CHF')
+        expect(format('CNY')(amount)).to.equal('1,00 ¥')
+        expect(format('COP')(amount)).to.equal('1,00 $')
+
+        expect(format('EUR')(amount)).to.equal('1,00 €')
+        expect(format('GBP')(amount)).to.equal('1,00 £')
+        expect(format('USD')(amount)).to.equal('1,00 $')
+      })
+
+      it('should format 123_456_789.987_654 "minimal atomic units"', function () {
+        const amount = 123_456_789.987_654
+
+        // "no cents currencies"
+        expect(format('CLP')(amount)).to.equal('123 456 790 $')
+        expect(format('JPY')(amount)).to.equal('123 456 790 ¥')
+        expect(format('KRW')(amount)).to.equal('123 456 790 ₩')
+        expect(format('VND')(amount)).to.equal('123 456 790 ₫')
+
+        // other currencies
+        expect(format('AUD')(amount)).to.equal('1 234 567,90 $')
+        expect(format('BRL')(amount)).to.equal('1 234 567,90 R$')
+        expect(format('CAD')(amount)).to.equal('1 234 567,90 $')
+        expect(format('CHF')(amount)).to.equal('1 234 567,90 CHF')
+        expect(format('CNY')(amount)).to.equal('1 234 567,90 ¥')
+        expect(format('COP')(amount)).to.equal('1 234 567,90 $')
+
+        expect(format('EUR')(amount)).to.equal('1 234 567,90 €')
+        expect(format('GBP')(amount)).to.equal('1 234 567,90 £')
+        expect(format('USD')(amount)).to.equal('1 234 567,90 $')
+      })
+    })
+  })
+})

+ 44 - 21
services/web/test/unit/src/Subscription/SubscriptionHelperTests.js

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