From 25e44b012b2fa9ff145f053b1c5e7bf9c9b24626 Mon Sep 17 00:00:00 2001 From: Pavel Nikoltsev Date: Sat, 6 Aug 2022 21:34:11 +0400 Subject: [PATCH] feat: WIP add currency processing --- src/currency.ts | 144 ++++++++++++++++++++++++++++++++++++++++++ test/currency.test.ts | 96 ++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 src/currency.ts create mode 100644 test/currency.test.ts diff --git a/src/currency.ts b/src/currency.ts new file mode 100644 index 0000000..ffc9e16 --- /dev/null +++ b/src/currency.ts @@ -0,0 +1,144 @@ +export type Separators = { + int: string + decimal: string + point: string +} +export type Precisions = { + int: number + decimal: number +} + +export type Currency = string +export const ERR_INVALID_TYPE = '"num" has invalid type' +export const ERR_INTEGER_LIMIT = '"num" is greater then MAX_SAFE_INTEGER, consider using string or BigInt' +export const ERR_INVALID_STRING = '"num" is invalid string, valid pattern is "100.100" or "100,100"' + +export function toCurrency( + num: string | number | BigInt, + currency: string = toCurrency.currency, + precision: Precisions = toCurrency.precision, + separators: Separators = toCurrency.separators +): Currency { + let int = 0n + let decimal = 0n + let parts: [string, string] = ['', ''] + switch (typeof num) { + case 'bigint': + num = BigInt(num) + break + case 'number': + if (num > Number.MAX_SAFE_INTEGER) { + throw new Error(ERR_INTEGER_LIMIT) + } + num = String(num) + case 'string': + switch (true) { + case /^\d+$/.test(num): + parts[0] = num + case /^\d+\.\d+$/.test(num): + parts = num.split('.') as [string, string] + break + case /^\d+\,\d+$/.test(num): + parts = num.split(',') as [string, string] + break + default: + throw new Error(ERR_INVALID_STRING) + } + int = BigInt(parts[0]) + if (!parts[1]) parts[1] = '0' + decimal = BigInt(parts[1]) + formatDecimal.initial = decimal + break + + default: + throw new Error(ERR_INVALID_TYPE) + } + if (decimal !== 0n) { + decimal = roundInt(decimal, precision.decimal) + if (precision.int !== 0) { + if (decimal >= 5) { + int += 1n + decimal = 0n + } + } + } + int = roundInt(int, precision.int) + return `${currency}${formatInt(int, separators.int)}${separators.point}${formatDecimal( + decimal, + separators.decimal, + precision.decimal + )}` +} +toCurrency.currency = '$' +toCurrency.precision = { int: 0, decimal: 2 } +toCurrency.separators = { int: '.', point: ',', decimal: ' ' } + +export function roundInt(n: BigInt, precision: number) { + let m = Array.from(n.toString(), s => Number(s)) + precision = precision >= 0 ? precision : m.length + let counter = 0 + let summand = 0 + let i = m.length - 1 + for (; i >= 0; i--) { + m[i] += summand + if (m[i] >= 5) { + summand = 1 + } else { + summand = 0 + } + if (counter >= precision - 1) { + break + } else { + if (summand) { + setPreviousZeros(m, i) + m[i] = 0 + } + } + counter++ + } + if (i > 0 && summand) { + setPreviousZeros(m, i) + m[i] = 0 + } + return BigInt(m.join('')) +} + +export function setPreviousZeros(nums: number[], idx: number) { + for (let i = idx; i < nums.length; i++) { + nums[i] = 0 + } + return nums +} + +export function formatInt(num: BigInt, separator: string) { + return format(num.toString(), separator) +} +export function formatDecimal(num: BigInt, separator: string, precision: number) { + let s = '' + precision = precision >= 0 ? precision : formatDecimal.initial.toString().length + if (num === 0n) { + s = '0'.repeat(precision || 1) + } else { + s = num.toString() + } + return format(s, separator) +} +formatDecimal.initial = 0n + +export function format(s: string, separator: string) { + let arr = Array.from(s) + let buffer = [] + let counter = 1 + for (let i = arr.length - 1; i >= 0; i--) { + buffer.push(arr[i]) + if (counter % 3 === 0 && i !== 0) { + buffer.push(separator) + } + counter++ + } + return buffer.reverse().join('').trim() +} + +// export function fromCurrency(value: Currency): BigInt { +// return 0n +// } diff --git a/test/currency.test.ts b/test/currency.test.ts new file mode 100644 index 0000000..68607a6 --- /dev/null +++ b/test/currency.test.ts @@ -0,0 +1,96 @@ +// import { toCurrency, ERR_INVALID_TYPE, ERR_INTEGER_LIMIT, ERR_INVALID_STRING, roundInt } from '../src/currency' +import { + ERR_INTEGER_LIMIT, + ERR_INVALID_STRING, + ERR_INVALID_TYPE, + formatDecimal, + formatInt, + roundInt, + toCurrency +} from '../src/currency' + +function testFormat(fn: (n: BigInt, s: string, ...args: any[]) => string) { + it('should return proper value', () => { + expect(fn(1000n, ' ', 0)).toEqual('1 000') + expect(fn(10000n, ' ', 0)).toEqual('10 000') + expect(fn(100000n, ' ', 0)).toEqual('100 000') + expect(fn(1000000n, ' ', 0)).toEqual('1 000 000') + }) +} + +describe('formatInt', () => { + it('should return proper value for zeros', () => { + expect(formatInt(0n, ' ')).toEqual('0') + }) + testFormat(formatInt) +}) + +describe('formatDecimal', () => { + it('should return proper value for zeros', () => { + expect(formatDecimal(0n, ' ', -1)).toEqual('0') + expect(formatDecimal(0n, ' ', 0)).toEqual('0') + expect(formatDecimal(0n, ' ', 1)).toEqual('0') + expect(formatDecimal(0n, ' ', 2)).toEqual('00') + expect(formatDecimal(0n, ' ', 3)).toEqual('000') + expect(formatDecimal(0n, ' ', 4)).toEqual('0 000') + expect(formatDecimal(0n, ' ', 5)).toEqual('00 000') + expect(formatDecimal(0n, ' ', 6)).toEqual('000 000') + expect(formatDecimal(0n, ' ', 7)).toEqual('0 000 000') + }) + testFormat(formatDecimal) +}) + +describe('roundInt', () => { + it('should return proper value for different values', () => { + expect(roundInt(445n, 3)).toEqual(500n) + expect(roundInt(9945n, 2)).toEqual(9900n) + expect(roundInt(99454n, 3)).toEqual(99000n) + expect(roundInt(190000000000453n, 15)).toEqual(200000000000000n) + expect(roundInt(190000000000453n, 0)).toEqual(190000000000453n) + }) + it('should return proper value with negative precision', () => { + expect(roundInt(190000000000453n, -1)).toEqual(200000000000000n) + }) +}) + +describe('toCurrency', () => { + it('should return proper value for all types of first arg', () => { + expect(toCurrency('100000')).toEqual('$100.000,00') + expect(toCurrency('1000.00')).toEqual('$1.000,00') + expect(toCurrency('1000,00')).toEqual('$1.000,00') + expect(toCurrency('100')).toEqual('$100,00') + expect(toCurrency(100000)).toEqual('$100.000,00') + expect(toCurrency(100000.01)).toEqual('$100.000,01') + expect(toCurrency(100000n)).toEqual('$100.000,00') + // expect(toCurrency(Number.MAX_SAFE_INTEGER)).toEqual('$100,000,000,000,000,000,000.00') + }) + it('should return proper value for different currencies', () => { + expect(toCurrency('100000', '€')).toEqual('€100,000.00') + }) + it('should return proper value for different precisions', () => { + expect(toCurrency('100000', '€', { int: 2, decimal: 2 })).toEqual('€100,000.000') + expect(toCurrency('100000', '€', { int: 0, decimal: 0 })).toEqual('€100,000') + }) + it('should return proper value for different separators', () => { + expect(toCurrency('100000', '€', { int: 3, decimal: 4 }, { int: '/', decimal: '*', point: '.' })).toEqual( + '€100/000.0*000' + ) + }) + it('should throw an error if first arg has invalid type', () => { + expect(() => toCurrency({} as unknown as string)).toThrow(ERR_INVALID_TYPE) + }) + it('should throw an error if first arg has invalid value', () => { + expect(() => toCurrency(Number.MAX_SAFE_INTEGER + 10)).toThrow(ERR_INTEGER_LIMIT) + expect(() => toCurrency('abc.abc')).toThrow(ERR_INVALID_STRING) + expect(() => toCurrency('abc,abc')).toThrow(ERR_INVALID_STRING) + expect(() => toCurrency('100.100.100')).toThrow(ERR_INVALID_STRING) + expect(() => toCurrency('100,100,100')).toThrow(ERR_INVALID_STRING) + }) + it('should return proper value for negative values', () => { + expect(toCurrency('-100000')).toEqual('-$100,000.00') + }) + it('should return proper value for fractional values', () => { + expect(toCurrency('100000.01')).toEqual('$100,000.01') + expect(toCurrency('100000,01')).toEqual('$100,000.01') + }) +})