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