From 4e023f06d46608e1f6f22b2f8424f71a8b4b6535 Mon Sep 17 00:00:00 2001 From: George Suntres Date: Mon, 11 May 2026 16:02:02 -0400 Subject: [PATCH] first commit --- .github/workflows/node.js.yml | 31 +++++ .gitignore | 7 + LICENSE | 21 +++ README.md | 51 ++++++++ package-lock.json | 54 ++++++++ package.json | 19 +++ src/Currency.mjs | 234 ++++++++++++++++++++++++++++++++++ src/Currency.test.mjs | 234 ++++++++++++++++++++++++++++++++++ src/Formatter.mjs | 137 ++++++++++++++++++++ src/Formatter.test.mjs | 118 +++++++++++++++++ src/index.mjs | 7 + 11 files changed, 913 insertions(+) create mode 100644 .github/workflows/node.js.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/Currency.mjs create mode 100644 src/Currency.test.mjs create mode 100644 src/Formatter.mjs create mode 100644 src/Formatter.test.mjs create mode 100644 src/index.mjs diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..2284b93 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fe529e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env.* +*-debug.log +build +.npm +.idea +.dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c5c33d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d53e843 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ffe3bac --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8f263bf --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/Currency.mjs b/src/Currency.mjs new file mode 100644 index 0000000..d0e2c35 --- /dev/null +++ b/src/Currency.mjs @@ -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 +} \ No newline at end of file diff --git a/src/Currency.test.mjs b/src/Currency.test.mjs new file mode 100644 index 0000000..b63926b --- /dev/null +++ b/src/Currency.test.mjs @@ -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 +}] \ No newline at end of file diff --git a/src/Formatter.mjs b/src/Formatter.mjs new file mode 100644 index 0000000..2957a90 --- /dev/null +++ b/src/Formatter.mjs @@ -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 +} diff --git a/src/Formatter.test.mjs b/src/Formatter.test.mjs new file mode 100644 index 0000000..5acaf17 --- /dev/null +++ b/src/Formatter.test.mjs @@ -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: '€' + }]) + }) +}) \ No newline at end of file diff --git a/src/index.mjs b/src/index.mjs new file mode 100644 index 0000000..66bf0e2 --- /dev/null +++ b/src/index.mjs @@ -0,0 +1,7 @@ +import { Currency } from './Currency.mjs' + +export const currency = new Currency() + +export { + Currency +} \ No newline at end of file