first commit
This commit is contained in:
31
.github/workflows/node.js.yml
vendored
Normal file
31
.github/workflows/node.js.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
||||||
|
|
||||||
|
name: Node.js CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18.x, 20.x, 22.x]
|
||||||
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'npm'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build --if-present
|
||||||
|
- run: npm test
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env.*
|
||||||
|
*-debug.log
|
||||||
|
build
|
||||||
|
.npm
|
||||||
|
.idea
|
||||||
|
.dist
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 George Suntres
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
## Currency formatter based on Intl.NumberFormat
|
||||||
|
|
||||||
|
```@needme/currency``` offers a convenient way to format currencies using multiple configurations.
|
||||||
|
|
||||||
|
*Install*
|
||||||
|
```bash
|
||||||
|
npm i --save @needem/currency
|
||||||
|
```
|
||||||
|
|
||||||
|
*Basic use*
|
||||||
|
```js
|
||||||
|
import { currency } from '@needme/currency'
|
||||||
|
|
||||||
|
currency.add({
|
||||||
|
locale: 'en-US',
|
||||||
|
iso: 'usd'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(currency.format(100.99))
|
||||||
|
// should display $100.00
|
||||||
|
```
|
||||||
|
|
||||||
|
You can configure multiple formatters in one go by using the ```init``` function.
|
||||||
|
|
||||||
|
Choose which one to use with the ```use``` function.
|
||||||
|
|
||||||
|
|
||||||
|
*Initialize with multiple locales*
|
||||||
|
```js
|
||||||
|
import { currency } from '@needme/currency'
|
||||||
|
|
||||||
|
currency.init({
|
||||||
|
default: 'fr',
|
||||||
|
configs: [{
|
||||||
|
locale: 'en-US',
|
||||||
|
iso: 'usd',
|
||||||
|
}, {
|
||||||
|
name: 'mx',
|
||||||
|
locale: 'es-MX',
|
||||||
|
iso: 'mxv'
|
||||||
|
}, {
|
||||||
|
locale: 'fr',
|
||||||
|
iso: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
currency.use('mx')
|
||||||
|
currency.format(120.56)
|
||||||
|
// should display MXV 120.56
|
||||||
|
```
|
||||||
54
package-lock.json
generated
Normal file
54
package-lock.json
generated
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@needem/currency",
|
||||||
|
"version": "0.4.3",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@needem/currency",
|
||||||
|
"version": "0.4.3",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw=="
|
||||||
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
||||||
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@needem/currency",
|
||||||
|
"version": "0.4.3",
|
||||||
|
"description": "Currency formating using Intl",
|
||||||
|
"main": "src/index.mjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/**/*.mjs",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"author": "gsuntres@pm.me",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/Currency.mjs
Normal file
234
src/Currency.mjs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import Ajv from 'ajv'
|
||||||
|
import { isNil } from 'lodash-es'
|
||||||
|
import { Formatter } from './Formatter.mjs'
|
||||||
|
|
||||||
|
export class Currency {
|
||||||
|
|
||||||
|
#formatters = {}
|
||||||
|
|
||||||
|
#activeFormatter
|
||||||
|
|
||||||
|
#flash
|
||||||
|
|
||||||
|
#ajv
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#ajv = new Ajv()
|
||||||
|
|
||||||
|
this.init = this.init.bind(this)
|
||||||
|
this.use = this.use.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
format(value, config) {
|
||||||
|
const finalConfig = Object.assign({}, config || {})
|
||||||
|
|
||||||
|
let finalValue
|
||||||
|
if(typeof value === 'number') {
|
||||||
|
finalValue = value
|
||||||
|
} else if(typeof value === 'object' && !isNil(value.value) && value.currency) {
|
||||||
|
finalValue = value.value
|
||||||
|
finalConfig.currency = config?.currency || value.currency
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid value')
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.keys(this.#formatters).length === 0) {
|
||||||
|
throw new Error('no formatters found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = this.#activeFormatter
|
||||||
|
|
||||||
|
if(this.#flash) {
|
||||||
|
name = this.#flash
|
||||||
|
|
||||||
|
this.#flash = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if(finalConfig.name) {
|
||||||
|
name = finalConfig.name
|
||||||
|
delete finalConfig.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = this.#formatters[name]
|
||||||
|
|
||||||
|
if(!formatter) {
|
||||||
|
throw new Error('invalid formatter')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter.format(finalValue, finalConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatToParts(value, config) {
|
||||||
|
const finalConfig = Object.assign({}, config || {})
|
||||||
|
|
||||||
|
let finalValue
|
||||||
|
if(typeof value === 'number') {
|
||||||
|
finalValue = value
|
||||||
|
} else if(typeof value === 'object' && value.value && value.currency) {
|
||||||
|
finalValue = value.value
|
||||||
|
finalConfig.currency = value.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.keys(this.#formatters).length === 0) {
|
||||||
|
throw new Error('no formatters found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = this.#activeFormatter
|
||||||
|
|
||||||
|
if(this.#flash) {
|
||||||
|
name = this.#flash
|
||||||
|
|
||||||
|
this.#flash = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if(finalConfig.name) {
|
||||||
|
name = finalConfig.name
|
||||||
|
delete finalConfig.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = this.#formatters[name]
|
||||||
|
|
||||||
|
if(!formatter) {
|
||||||
|
throw new Error('invalid formatter')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter.formatToParts(finalValue, finalConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new currency.
|
||||||
|
*
|
||||||
|
* @param {object} config
|
||||||
|
* @param [string] config.name A name to use as an alias for currency.
|
||||||
|
* If not defined locale will be used.
|
||||||
|
* @param [string] config.locale Locale to use (default: en)
|
||||||
|
* @param [string] config.currency Currency currency to use (default: usd)
|
||||||
|
* @param [string] config.display Display style to use (default: narrow)
|
||||||
|
* @param [boolean] config.fromCents Does value has cents included (default: false)
|
||||||
|
*/
|
||||||
|
add(config = {
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd'
|
||||||
|
}) {
|
||||||
|
const name = config.name || config.locale
|
||||||
|
|
||||||
|
if(this.#formatters[name]) {
|
||||||
|
return this.#formatters[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
delete config.name
|
||||||
|
|
||||||
|
const formatter = Formatter.create(config)
|
||||||
|
|
||||||
|
this.#formatters[name] = formatter
|
||||||
|
|
||||||
|
this.#activeFormatter = name
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
use(name) {
|
||||||
|
if(!this.#formatters[name]) {
|
||||||
|
throw new Error('invalid formatter')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#activeFormatter = name
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
if(Object.keys(this.#formatters).length === 0) {
|
||||||
|
throw new Error('no formatters found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = this.#formatters[this.#activeFormatter]
|
||||||
|
|
||||||
|
if(!formatter) {
|
||||||
|
throw new Error('invalid formatter')
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
flash(name) {
|
||||||
|
if(!this.#formatters[name]) {
|
||||||
|
throw new Error('invalid formatter')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#flash = name
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize currency instance
|
||||||
|
*
|
||||||
|
* @param {object} config
|
||||||
|
* @param [string] config.default (default: last language in the list)
|
||||||
|
* @param {object[]} config.configs
|
||||||
|
* @param [string] config.configs[].name
|
||||||
|
* @param {string} config.configs[].locale
|
||||||
|
* @param {string} config.configs[].currency
|
||||||
|
* @param [boolean] config.configs[].fromCents (default: false)
|
||||||
|
*/
|
||||||
|
init(config) {
|
||||||
|
const valid = this.#ajv.validate(INIT_CONFIG, config)
|
||||||
|
if(!valid) {
|
||||||
|
throw this.#ajv.errors[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const conf of config.configs) {
|
||||||
|
this.add(conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(config.default) {
|
||||||
|
this.use(config.default)
|
||||||
|
} else {
|
||||||
|
const lastConfig = config.configs[config.configs.length - 1]
|
||||||
|
this.use(lastConfig.name || lastConfig.locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.#formatters = {}
|
||||||
|
this.#activeFormatter = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const INIT_CONFIG = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
default: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
configs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
fromCents: {
|
||||||
|
type: 'boolean'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['locale', 'currency'],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
minItems: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
234
src/Currency.test.mjs
Normal file
234
src/Currency.test.mjs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe } from 'node:test'
|
||||||
|
import { it } from 'node:test'
|
||||||
|
import { before } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { Currency } from './Currency.mjs'
|
||||||
|
import { Formatter } from './Formatter.mjs'
|
||||||
|
|
||||||
|
const now = new Date('2024-05-21T20:49:14.423Z')
|
||||||
|
|
||||||
|
describe('Currency', () => {
|
||||||
|
|
||||||
|
let c
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
c = new Currency()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add default', () => {
|
||||||
|
c.add()
|
||||||
|
assert.equal(c.format(1000), '$1,000.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add currency', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'es-MX',
|
||||||
|
currency: 'mxv'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format(12345), 'MXV 12,345.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use custom alias', () => {
|
||||||
|
c.add({
|
||||||
|
name: 'mx',
|
||||||
|
locale: 'es-MX',
|
||||||
|
currency: 'mxv'
|
||||||
|
})
|
||||||
|
|
||||||
|
c.add({
|
||||||
|
name: 'no',
|
||||||
|
locale: 'no-NO',
|
||||||
|
currency: 'nok'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format(111000, { name: 'mx' }), 'MXV 111,000.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format with cents', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format(15989), '159,89 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format object values', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format({
|
||||||
|
value: 15989,
|
||||||
|
currency: 'usd'
|
||||||
|
}), '159,89 $')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format object values (force currency override)', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format({
|
||||||
|
value: 15989,
|
||||||
|
currency: 'usd'
|
||||||
|
}, { currency: 'mxd' }), '159,89 MXD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should override with cents', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format(15989, { fromCents: false }), '15.989,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear instance', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
c.clear()
|
||||||
|
|
||||||
|
assert.throws(() => c.format(15989), {
|
||||||
|
message: 'no formatters found'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw on unknown formatter on format', () => {
|
||||||
|
c.add({
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.throws(() => c.format(15989, { name: 'bla' }), {
|
||||||
|
message: 'invalid formatter'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when switching to an unknown formatter', () => {
|
||||||
|
assert.throws(() => c.use('bla'), {
|
||||||
|
message: 'invalid formatter'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should flash', () => {
|
||||||
|
c.init({
|
||||||
|
configs: [{
|
||||||
|
name: 'mx',
|
||||||
|
locale: 'es-MX',
|
||||||
|
currency: 'mxv'
|
||||||
|
}, {
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}, {
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.flash('mx').format(1599.00), 'MXV 1,599.00')
|
||||||
|
assert.equal(c.format(159900), '1 599,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch to a different formatter', () => {
|
||||||
|
c.init({ configs: SAMPLE_INIT })
|
||||||
|
|
||||||
|
assert.equal(c.format(1000), '10,00 €')
|
||||||
|
|
||||||
|
c.use('mx')
|
||||||
|
assert.equal(c.format(3000.66), 'MXV 3,000.66')
|
||||||
|
assert.equal(c.format(3020.66), 'MXV 3,020.66')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should init', () => {
|
||||||
|
c.init({
|
||||||
|
default: 'fr',
|
||||||
|
configs: SAMPLE_INIT
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(c.format(1599), '15,99 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch formatter on the fly', () => {
|
||||||
|
c.init({ configs: SAMPLE_INIT })
|
||||||
|
|
||||||
|
assert.equal(c.format(1599, { name: 'mx' }), 'MXV 1,599.00')
|
||||||
|
assert.equal(c.format(1599), '15,99 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format to parts', () => {
|
||||||
|
c.init({
|
||||||
|
configs: [{
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(c.formatToParts(1004522), [{
|
||||||
|
type: 'integer', value: '10'
|
||||||
|
}, {
|
||||||
|
type: 'group', value: ' '
|
||||||
|
}, {
|
||||||
|
type: 'integer', value: '045'
|
||||||
|
}, {
|
||||||
|
type: 'decimal', value: ','
|
||||||
|
}, {
|
||||||
|
type: 'fraction', value: '22'
|
||||||
|
}, {
|
||||||
|
type: 'literal', value: ' '
|
||||||
|
}, {
|
||||||
|
type: 'currency', value: '€'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get current', () => {
|
||||||
|
c.init({
|
||||||
|
configs: [{
|
||||||
|
name: 'mx',
|
||||||
|
locale: 'es-MX',
|
||||||
|
currency: 'mxv'
|
||||||
|
}, {
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}, {
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ok(c.current() instanceof Formatter)
|
||||||
|
assert.equal(''+c.current(), 'fr')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
const SAMPLE_INIT = [{
|
||||||
|
name: 'mx',
|
||||||
|
locale: 'es-MX',
|
||||||
|
currency: 'mxv'
|
||||||
|
}, {
|
||||||
|
locale: 'el',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}, {
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
}]
|
||||||
137
src/Formatter.mjs
Normal file
137
src/Formatter.mjs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Ajv from 'ajv'
|
||||||
|
|
||||||
|
export class Formatter {
|
||||||
|
|
||||||
|
#ajv
|
||||||
|
|
||||||
|
#locale
|
||||||
|
|
||||||
|
#currency
|
||||||
|
|
||||||
|
#display
|
||||||
|
|
||||||
|
#fromCents
|
||||||
|
|
||||||
|
#actual
|
||||||
|
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.#ajv = new Ajv()
|
||||||
|
|
||||||
|
this.#validate(config)
|
||||||
|
|
||||||
|
this.#locale = config.locale
|
||||||
|
this.#currency = config.currency
|
||||||
|
this.#display = config.display || 'narrow'
|
||||||
|
this.#fromCents = config.fromCents || false
|
||||||
|
|
||||||
|
this.#actual = this.#createActual({
|
||||||
|
locale: this.#locale,
|
||||||
|
currency: this.#currency,
|
||||||
|
display: this.#display
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(config) {
|
||||||
|
return new Formatter(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
format(value, config) {
|
||||||
|
this.#validate(value, {
|
||||||
|
type: 'number',
|
||||||
|
multipleOf : this.#fromCents ? 1 : 0.01
|
||||||
|
})
|
||||||
|
|
||||||
|
let actual = this.#actual
|
||||||
|
if(config) {
|
||||||
|
const newConfig = Object.assign({}, this.config, config)
|
||||||
|
|
||||||
|
let v = newConfig.fromCents ? value / 100 : value
|
||||||
|
|
||||||
|
return this.#createActual(newConfig).format(v)
|
||||||
|
} else {
|
||||||
|
let v = this.#fromCents ? value / 100 : value
|
||||||
|
|
||||||
|
return this.#actual.format(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatToParts(value, config) {
|
||||||
|
const valid = this.#validate(value, {
|
||||||
|
type: 'number',
|
||||||
|
multipleOf : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
let actual = this.#actual
|
||||||
|
if(config) {
|
||||||
|
const newConfig = Object.assign({}, this.config, config)
|
||||||
|
|
||||||
|
let v = newConfig.fromCents ? value / 100 : value
|
||||||
|
|
||||||
|
return this.#createActual(newConfig).formatToParts(v)
|
||||||
|
} else {
|
||||||
|
let v = this.#fromCents ? value / 100 : value
|
||||||
|
|
||||||
|
return this.#actual.formatToParts(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.#locale
|
||||||
|
}
|
||||||
|
|
||||||
|
#createActual(config) {
|
||||||
|
this.#validate(config)
|
||||||
|
|
||||||
|
let currencyDisplay
|
||||||
|
switch(config.display) {
|
||||||
|
case 'narrow':
|
||||||
|
currencyDisplay = 'narrowSymbol'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
currencyDisplay = 'symbol'
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat(config.locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: config.currency,
|
||||||
|
currencyDisplay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#validate(v, schema = CONFIG) {
|
||||||
|
let valid = this.#ajv.validate(schema, v)
|
||||||
|
if(!valid) {
|
||||||
|
throw this.#ajv.errors[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get config() {
|
||||||
|
return {
|
||||||
|
locale: this.#locale,
|
||||||
|
currency: this.#currency,
|
||||||
|
display: this.#display,
|
||||||
|
fromCents: this.#fromCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
locale: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
fromCents: {
|
||||||
|
type: 'boolean'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['locale', 'currency'],
|
||||||
|
additionalProperties: false
|
||||||
|
}
|
||||||
118
src/Formatter.test.mjs
Normal file
118
src/Formatter.test.mjs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe } from 'node:test'
|
||||||
|
import { it } from 'node:test'
|
||||||
|
import { before } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { Formatter } from './Formatter.mjs'
|
||||||
|
|
||||||
|
describe('Formatter', () => {
|
||||||
|
|
||||||
|
let f
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
f = Formatter.create({
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create default formatter', () => {
|
||||||
|
assert.deepEqual(f.config, {
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd',
|
||||||
|
display: 'narrow' ,
|
||||||
|
fromCents: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format value', () => {
|
||||||
|
assert.equal(f.format(1500), '$1,500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format decimal values', () => {
|
||||||
|
const f = Formatter.create({
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd',
|
||||||
|
fromCents: false
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(f.format(100.99), '$100.99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format value from cents', () => {
|
||||||
|
const f = Formatter.create({
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
assert.equal(f.format(1599), '$15.99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format zero', () => {
|
||||||
|
assert.equal(f.format(0), '$0.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when not integer', () => {
|
||||||
|
const f = Formatter.create({
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'usd',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.throws(() => { f.format(0.1000) }, { message: 'must be multiple of 1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should override locale', () => {
|
||||||
|
assert.equal(f.format(1500, { locale: 'fr' }), '1 500,00 $')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display decimal with comma', () => {
|
||||||
|
const fr = Formatter.create({
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(fr.format(1500), '1 500,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display decimal with dot', () => {
|
||||||
|
const fr = Formatter.create({
|
||||||
|
locale: 'en',
|
||||||
|
currency: 'eur'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(fr.format(1500), '€1,500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('culturally diverse', () => {
|
||||||
|
const fa = Formatter.create({
|
||||||
|
locale: 'fa',
|
||||||
|
currency: 'irr'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(fa.format(10022), 'ریال ۱۰٬۰۲۲')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format to parts', () => {
|
||||||
|
const fr = Formatter.create({
|
||||||
|
locale: 'fr',
|
||||||
|
currency: 'eur',
|
||||||
|
fromCents: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(fr.formatToParts(1004522), [{
|
||||||
|
type: 'integer', value: '10'
|
||||||
|
}, {
|
||||||
|
type: 'group', value: ' '
|
||||||
|
}, {
|
||||||
|
type: 'integer', value: '045'
|
||||||
|
}, {
|
||||||
|
type: 'decimal', value: ','
|
||||||
|
}, {
|
||||||
|
type: 'fraction', value: '22'
|
||||||
|
}, {
|
||||||
|
type: 'literal', value: ' '
|
||||||
|
}, {
|
||||||
|
type: 'currency', value: '€'
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
})
|
||||||
7
src/index.mjs
Normal file
7
src/index.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Currency } from './Currency.mjs'
|
||||||
|
|
||||||
|
export const currency = new Currency()
|
||||||
|
|
||||||
|
export {
|
||||||
|
Currency
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user