mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
[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:
parent
76955c814a
commit
b2ef7a935f
22 changed files with 985 additions and 437 deletions
|
@ -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
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const dateformat = require('dateformat')
|
||||
const { formatCurrencyLocalized } = require('../../util/currency')
|
||||
|
||||
const currencySymbols = {
|
||||
EUR: '€',
|
||||
|
@ -20,8 +21,7 @@ const currencySymbols = {
|
|||
PEN: 'S/',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatPrice(priceInCents, currency) {
|
||||
function formatPriceDefault(priceInCents, currency) {
|
||||
if (!currency) {
|
||||
currency = 'USD'
|
||||
} else if (currency === 'CLP') {
|
||||
|
@ -46,12 +46,37 @@ module.exports = {
|
|||
const dollars = string.slice(0, -2)
|
||||
const symbol = currencySymbols[currency]
|
||||
return `${symbol}${dollars}.${cents}`
|
||||
},
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
/**
|
||||
* @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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
switch (recommendedCurrency) {
|
||||
case 'CHF': {
|
||||
/**
|
||||
* @param {number} price
|
||||
* @returns {string}
|
||||
*/
|
||||
const formatPrice = price =>
|
||||
formatCurrency(price, recommendedCurrency, locale, true)
|
||||
|
||||
return {
|
||||
price: {
|
||||
collaborator: `${recommendedCurrencySymbol} ${collaboratorPrice}`,
|
||||
professional: `${recommendedCurrencySymbol} ${professionalPrice}`,
|
||||
collaborator: formatPrice(collaboratorPrice),
|
||||
professional: formatPrice(professionalPrice),
|
||||
},
|
||||
pricePerUser: {
|
||||
collaborator: `${recommendedCurrencySymbol} ${collaboratorPricePerUser}`,
|
||||
professional: `${recommendedCurrencySymbol} ${professionalPricePerUser}`,
|
||||
collaborator: formatPrice(collaboratorPricePerUser),
|
||||
professional: formatPrice(professionalPricePerUser),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrencyDefault(amount, recommendedCurrency) {
|
||||
const currencySymbols = Settings.groupPlanModalOptions.currencySymbols
|
||||
const recommendedCurrencySymbol = currencySymbols[recommendedCurrency]
|
||||
|
||||
switch (recommendedCurrency) {
|
||||
case 'CHF': {
|
||||
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,
|
||||
}
|
||||
|
|
|
@ -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,18 +316,18 @@ async function buildUsersSubscriptionViewModel(user) {
|
|||
const pendingSubscriptionTax =
|
||||
personalSubscription.recurly.taxRate *
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
personalSubscription.recurly.displayPrice = formatPrice(
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents +
|
||||
pendingAddOnPrice +
|
||||
pendingAddOnTax +
|
||||
pendingSubscriptionTax,
|
||||
recurlySubscription.currency
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
personalSubscription.recurly.currentPlanDisplayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
personalSubscription.recurly.currentPlanDisplayPrice = formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
const pendingTotalLicenses =
|
||||
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
|
||||
|
@ -332,10 +336,10 @@ async function buildUsersSubscriptionViewModel(user) {
|
|||
personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
|
||||
personalSubscription.pendingPlan = pendingPlan
|
||||
} else {
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPrice(
|
||||
personalSubscription.recurly.displayPrice = formatPrice(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
34
services/web/app/src/util/currency.js
Normal file
34
services/web/app/src/util/currency.js
Normal 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,
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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], {
|
||||
return amount.toLocaleString(LOCALES[currency], {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
}),
|
||||
localizedPerUserPrice: perUserPrice.toLocaleString(LOCALES[currency], {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: Number.isInteger(perUserPrice) ? 0 : null,
|
||||
}),
|
||||
}
|
||||
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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?.totalAsNumber !== undefined) {
|
||||
plan.displayPrice = formatCurrency(
|
||||
priceData.totalAsNumber,
|
||||
currency,
|
||||
i18n.language
|
||||
)
|
||||
if (priceData?.totalForDisplay) {
|
||||
plan.displayPrice = priceData.totalForDisplay
|
||||
}
|
||||
} 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(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
services/web/frontend/js/shared/utils/currency.ts
Normal file
39
services/web/frontend/js/shared/utils/currency.ts
Normal 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',
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 $')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in a new issue