Created
October 24, 2024 14:58
-
-
Save stctheproducer/e8aa5fa2767af85dc112de8fc359f0ba to your computer and use it in GitHub Desktop.
Money class with mathjs evaluation and dinero.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as currencies from '@dinero.js/currencies' | |
import { | |
allocate, | |
createDinero, | |
dinero, | |
type Dinero, | |
type Calculator, | |
type Currency, | |
type DivideOperation, | |
add, | |
subtract, | |
multiply, | |
greaterThanOrEqual, | |
greaterThan, | |
toDecimal, | |
lessThan, | |
lessThanOrEqual, | |
equal, | |
isNegative, | |
isZero, | |
isPositive, | |
toSnapshot, | |
toUnits, | |
convert, | |
minimum, | |
maximum, | |
haveSameAmount, | |
haveSameCurrency, | |
hasSubUnits, | |
normalizeScale, | |
transformScale, | |
trimScale, | |
type DineroSnapshot, | |
} from 'dinero.js' | |
import { evaluate } from 'mathjs' | |
const ComparisonOperator = { | |
LT: -1, | |
EQ: 0, | |
GT: 1, | |
} as const | |
const calculator: Calculator<bigint> = { | |
add: (augend, addend) => augend + addend, | |
compare: (a, b) => { | |
if (a < b) { | |
return ComparisonOperator.LT | |
} | |
if (a > b) { | |
return ComparisonOperator.GT | |
} | |
return ComparisonOperator.EQ | |
}, | |
decrement: value => value - 1n, | |
increment: value => value + 1n, | |
integerDivide: (dividend, divisor) => dividend / divisor, | |
modulo: (dividend, divisor) => dividend % divisor, | |
multiply: (multiplicand, multiplier) => multiplicand * multiplier, | |
power: (base, exponent) => base ** exponent, | |
subtract: (minuend, subtrahend) => minuend - subtrahend, | |
zero: () => 0n, | |
} | |
type CurrencyCode = keyof typeof currencies | |
export class Money { | |
#value: Dinero<number> | |
#currency: Currency<number> | |
constructor( | |
num: number | Money | Dinero<number>, | |
currencyCode: string = 'ZMW', | |
isMinorCurrencyUnit: boolean = false, | |
) { | |
if (typeof num === 'number') { | |
let scale = Money.getScale(num), | |
currency = currencies[currencyCode as CurrencyCode] as Currency<number> | |
this.#currency = currency | |
scale += currency.exponent | |
const amount = isMinorCurrencyUnit | |
? num | |
: Money.convertToMinorCurrencyUnit(num, scale) | |
this.#value = dinero({ | |
amount, | |
currency, | |
scale, | |
}) | |
} else if (num instanceof Money) { | |
this.#currency = num.#currency | |
this.#value = num.#value | |
} else { | |
this.#currency = num.toJSON().currency | |
this.#value = num | |
} | |
} | |
static convertToMinorCurrencyUnit(num: number, scale: number) { | |
return Math.round(num * Math.pow(10, scale)) | |
} | |
static isDecimal(num: number) { | |
const numString = num.toString() | |
return numString.includes('.') | |
} | |
static getScale(num: number): number { | |
// Convert the number to a string | |
const numString = num.toString() | |
// Check if there is a decimal point | |
if (numString.includes('.')) { | |
// Split the string by the decimal point and return the length of the decimal part | |
return numString.split('.')[1]!.length | |
} else { | |
// If there's no decimal point, return 0 | |
return 0 | |
} | |
} | |
static getScaledAmount(num: number) { | |
let scale = Money.getScale(num) | |
let amount = Money.convertToMinorCurrencyUnit(num, scale) | |
return { amount, scale } | |
} | |
#convertNumberToDinero(num: number, isMinorCurrencyUnit: boolean = false) { | |
let scale = Money.getScale(num), | |
amount = isMinorCurrencyUnit | |
? num | |
: Money.convertToMinorCurrencyUnit(num, scale) | |
return dinero({ | |
amount, | |
currency: this.#currency, | |
scale, | |
}) | |
} | |
#getDineroInstance(num: Money | number | Dinero<number>) { | |
let val: Dinero<number> | |
if (num instanceof Money) { | |
val = num.#value | |
} else if (typeof num === 'number') { | |
val = this.#convertNumberToDinero(num) | |
} else { | |
val = num | |
} | |
return val | |
} | |
distribute(ratios: number[]) { | |
return allocate(this.#value, ratios).map(d => new Money(d)) | |
} | |
distributeEqually(numberOfShares: number) { | |
const ratios = Array.from({ length: numberOfShares }).fill(1) as number[] | |
return this.distribute(ratios) | |
} | |
distributeEquallyWithTax( | |
taxPercentage: number, | |
numberOfShares: number, | |
minimum: number | undefined = undefined, | |
) { | |
let percentage = Money.isDecimal(taxPercentage) | |
? Math.round(taxPercentage * 100) | |
: taxPercentage | |
const [tax, net] = this.distribute([percentage, 100]) | |
const ratios = Array.from({ length: numberOfShares }).fill(1) as number[] | |
let allocated = net! | |
.distribute(ratios) | |
.map((n, i) => (i === 0 ? n.add(tax!) : n)) | |
if (!minimum) { | |
return allocated | |
} | |
if (allocated[0]?.lessThan(minimum)) { | |
let first = new Money(minimum), | |
rest = new Money(this.#value) | |
.subtract(first) | |
.distributeEqually(numberOfShares - 1) | |
return [first, ...rest] | |
} | |
return allocated | |
} | |
distributeEquallyWithTaxAboveMinimum( | |
taxPercentage: number, | |
numberOfShares: number, | |
minimum: number, | |
) { | |
let percentage = Money.isDecimal(taxPercentage) | |
? Math.round(taxPercentage * 100) | |
: taxPercentage | |
const [tax, net] = this.distribute([percentage, 100]) | |
if (!net?.lessThanOrEqual(minimum)) { | |
return this | |
} | |
const ratios = Array.from({ length: numberOfShares }).fill(1) as number[] | |
let allocated = net! | |
.distribute(ratios) | |
.map((n, i) => (i === 0 ? n.add(tax!) : n)) | |
if (allocated[0]?.lessThan(minimum)) { | |
let first = new Money(minimum), | |
rest = new Money(this.#value) | |
.subtract(first) | |
.distributeEqually(numberOfShares - 1) | |
return [first, ...rest] | |
} | |
return allocated | |
} | |
distributeToDecimal(ratios: number[]) { | |
return allocate(this.#value, ratios).map(d => toDecimal(d)) | |
} | |
distributeToNumber(ratios: number[]) { | |
return allocate(this.#value, ratios).map(d => Number(toDecimal(d))) | |
} | |
add(addend: Money | number | Dinero<number>) { | |
let val = this.#getDineroInstance(addend) | |
return new Money(add(this.#value, val)) | |
} | |
addPercentage(num: number) { | |
let isDecimal = Money.isDecimal(num), | |
percent = isDecimal ? num : num / 100, | |
amt = multiply(this.#value, Money.getScaledAmount(percent)) | |
return new Money(add(this.#value, amt)) | |
} | |
subtract(subtrahend: Money | number | Dinero<number>) { | |
let val = this.#getDineroInstance(subtrahend) | |
return new Money(subtract(this.#value, val)) | |
} | |
subtractPercentage(num: number) { | |
let isDecimal = Money.isDecimal(num), | |
percent = isDecimal ? num : num / 100, | |
amt = multiply(this.#value, Money.getScaledAmount(percent)) | |
return new Money(subtract(this.#value, amt)) | |
} | |
multiply(multiplicand: number) { | |
let isDecimal = Money.isDecimal(multiplicand) | |
const amt = isDecimal ? Money.getScaledAmount(multiplicand) : multiplicand | |
return new Money(multiply(this.#value, amt)) | |
} | |
percentage(share: number, scale = 0) { | |
const power = scale + 1 | |
const rest = 100 ** power - share | |
const [chunk] = allocate(this.#value, [ | |
{ amount: share, scale }, | |
{ amount: rest, scale }, | |
]) | |
return new Money(chunk!) | |
} | |
divide(divisor: number) { | |
let isDecimal = Money.isDecimal(divisor) | |
if (isDecimal) { | |
return this.multiply(1 / divisor) | |
} | |
const ratios = Array.from({ length: divisor }).fill(1) as number[] | |
const maxShare = maximum(this.distribute(ratios).map(m => m.#value)) | |
return new Money(maxShare) | |
} | |
lessThan(num: Money | number | Dinero<number>) { | |
return lessThan(this.#value, this.#getDineroInstance(num)) | |
} | |
lessThanOrEqual(num: Money | number | Dinero<number>) { | |
return lessThanOrEqual(this.#value, this.#getDineroInstance(num)) | |
} | |
equal(num: Money | number | Dinero<number>) { | |
return equal(this.#value, this.#getDineroInstance(num)) | |
} | |
greaterThanOrEqual(num: Money | number | Dinero<number>) { | |
return greaterThanOrEqual(this.#value, this.#getDineroInstance(num)) | |
} | |
greaterThan(num: Money | number | Dinero<number>) { | |
return greaterThan(this.#value, this.#getDineroInstance(num)) | |
} | |
isNegative() { | |
return isNegative(this.#value) | |
} | |
isZero() { | |
return isZero(this.#value) | |
} | |
isPositive() { | |
return isPositive(this.#value) | |
} | |
absolute() { | |
if (this.isNegative()) { | |
return new Money(multiply(this.#value, -1)) | |
} | |
return this | |
} | |
minimum(...numbers: Array<number | Dinero<number> | Money>) { | |
let nums = numbers.map(n => this.#getDineroInstance(n)) | |
let min = minimum([this.#value, ...nums]) | |
return new Money(min) | |
} | |
maximum(...numbers: Array<number | Dinero<number> | Money>) { | |
let nums = numbers.map(n => this.#getDineroInstance(n)) | |
let max = maximum([this.#value, ...nums]) | |
return new Money(max) | |
} | |
haveSameAmount(...numbers: Array<number | Dinero<number> | Money>) { | |
let nums = numbers.map(n => this.#getDineroInstance(n)) | |
return haveSameAmount([this.#value, ...nums]) | |
} | |
haveSameCurrency(...numbers: Array<number | Dinero<number> | Money>) { | |
let nums = numbers.map(n => this.#getDineroInstance(n)) | |
return haveSameCurrency([this.#value, ...nums]) | |
} | |
hasSubUnits() { | |
return hasSubUnits(this.#value) | |
} | |
static fromSnapshot(snapshot: DineroSnapshot<number>) { | |
return new Money(dinero(snapshot)) | |
} | |
toSnapshot() { | |
return toSnapshot(this.#value) | |
} | |
toJSON() { | |
return this.#value.toJSON() | |
} | |
get value() { | |
return Number(this.toDecimal()) | |
} | |
get amount() { | |
return this.#value.toJSON().amount | |
} | |
toValue() { | |
return this.#value.toJSON().amount | |
} | |
toUnits() { | |
return toUnits(this.#value) | |
} | |
toDecimal() { | |
return toDecimal(this.#value) | |
} | |
toNumber() { | |
return Number(this.toDecimal()) | |
} | |
format(locale = 'en-ZM', options: Intl.NumberFormatOptions = {}) { | |
return toDecimal(this.#value, ({ value, currency }) => { | |
return Number(value).toLocaleString(locale, { | |
...options, | |
style: 'currency', | |
currency: currency.code, | |
}) | |
}) | |
} | |
normalizeScale(...numbers: Array<number | Dinero<number> | Money>) { | |
let nums = numbers.map(n => this.#getDineroInstance(n)) | |
return normalizeScale([this.#value, ...nums]).map(d => new Money(d)) | |
} | |
transformScale(scale: number, divide?: DivideOperation) { | |
scale = Math.round(scale) | |
return new Money(transformScale(this.#value, scale, divide)) | |
} | |
trimScale() { | |
return new Money(trimScale(this.#value)) | |
} | |
convert(currencyCode: string, conversionRate: number) { | |
if (currencyCode === this.#currency.code) { | |
return this | |
} | |
const currency = currencies[ | |
currencyCode as CurrencyCode | |
] as Currency<number> | |
const rates = { | |
[currencyCode]: Money.getScaledAmount(conversionRate), | |
} | |
return new Money(convert(this.#value, currency, rates)) | |
} | |
static get dineroBigInt() { | |
return createDinero({ calculator }) | |
} | |
static evaluateFormula( | |
formula: string, | |
variables: { [key: string]: number }, | |
currencyCode = 'ZMW', | |
) { | |
let numericVariables: { [key: string]: number } = {}, | |
hasError = false, | |
matches = formula.match(/\w+|\d+(\.\d+)?/g), | |
variablesList = Object.values(variables) | |
if (matches?.length !== variablesList.length) { | |
hasError = true | |
return new Money(0) | |
} | |
for (const [key, value] of Object.entries(variables)) { | |
if (typeof value !== 'number') { | |
hasError = true | |
break | |
} | |
numericVariables[key] = value | |
} | |
if (hasError) { | |
return new Money(0) | |
} | |
const amount = evaluate(formula, numericVariables) | |
return new Money(amount, currencyCode) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment