money-lib
Version:
TypeScript library to work with money
311 lines (257 loc) • 7.61 kB
text/typescript
import { config, getCurrency, getDefaultRounder, getLocale } from "./config";
import type { Cents, Money } from "./types";
// ------ Initialization ------ //
export const zero = (currency = config.defaultCurrency) => {
return { amount: 0, currency };
};
export const fromInt = (
amount: Cents,
currency = config.defaultCurrency
): Money => {
return { amount, currency };
};
export const fromFloat = (
amount: number,
currency = config.defaultCurrency,
round = getDefaultRounder()
): Money => {
const scale = getCurrencyScale(zero(currency));
return {
amount: round(amount * scale, 0),
currency,
};
};
export const fromIntString = (
amount: string,
currency = config.defaultCurrency
): Money => {
const parsed = parseInt(amount, 10);
return fromInt(Number.isNaN(parsed) || parsed === 0 ? 0 : parsed, currency);
};
export const fromFloatString = (
amount: string,
currency = config.defaultCurrency,
round = getDefaultRounder()
): Money => {
const parsed = parseFloat(amount);
return fromFloat(
Number.isNaN(parsed) || parsed === 0 ? 0 : parsed,
currency,
round
);
};
// ------ Serialization ------ //
export const toInt = (m: Money): Cents => {
return m.amount;
};
export const toFloat = (m: Money): number => {
const scale = getCurrencyScale(m);
const { whole, cents } = split(m);
return whole + cents / scale;
};
export const toString = (m: Money): string => {
return `${m.amount}`;
};
export const toFloatString = (m: Money): string => {
const scale = getCurrencyScale(m);
const { whole, cents } = split(m);
return `${whole}.${cents.toString().padStart(Math.log10(scale), "0")}`;
};
// ------ Arithmetics ------ //
export const add = (m1: Money, m2: Money, ...m: Money[]): Money => {
return {
...m1,
amount: [m2, ...m].reduce((sum, curr) => sum + curr.amount, m1.amount),
};
};
export const subtract = (m1: Money, m2: Money, ...m: Money[]): Money => {
return {
...m1,
amount: [m2, ...m].reduce((diff, curr) => diff - curr.amount, m1.amount),
};
};
export const multiply = (
m: Money,
multiplier: number, // | Money
round = getDefaultRounder()
): Money => {
return fromInt(round(m.amount * multiplier, 0), m.currency);
};
export const divide = (
m: Money,
divider: number, // | Money
round = getDefaultRounder()
): Money => {
return fromInt(round(m.amount / divider, 0), m.currency);
};
export const abs = (m: Money): Money => {
return {
...m,
amount: Math.abs(m.amount),
};
};
// ------ Comparison ------ //
export const compare = (a: Money, b: Money): -1 | 0 | 1 => {
if (a.amount === b.amount) {
return 0;
}
return a.amount > b.amount ? 1 : -1;
};
export const equals = (a: Money, b: Money): boolean => {
return compare(a, b) === 0;
};
export const greaterThan = (a: Money, b: Money): boolean => {
return compare(a, b) === 1;
};
export const greaterThanOrEqual = (a: Money, b: Money): boolean => {
return compare(a, b) >= 0;
};
export const lessThan = (a: Money, b: Money): boolean => {
return compare(a, b) === -1;
};
export const lessThanOrEqual = (a: Money, b: Money): boolean => {
return compare(a, b) <= 0;
};
export const isZero = (m: Money): boolean => {
return m.amount === 0;
};
export const isPositive = (m: Money): boolean => {
return m.amount > 0;
};
export const isNegative = (m: Money): boolean => {
return m.amount < 0;
};
export const min = (m1: Money, ...m: Money[]): Money => {
return [m1, ...m].reduce((acc, curr) => (lessThan(acc, curr) ? acc : curr));
};
export const max = (m1: Money, ...m: Money[]): Money => {
return [m1, ...m].reduce((acc, curr) =>
greaterThan(acc, curr) ? acc : curr
);
};
// ------ Validation ------ //
export const isValid = (m: any): m is Money => {
return Boolean(
typeof m === "object" &&
(m.amount || m.amount === 0) &&
(typeof m.currency === "undefined" ||
(typeof m.currency === "string" &&
m.currency.length > 0 &&
!!getCurrency(m.currency)))
);
};
// ------ Transformation ------ //
export const split = (m: Money): { whole: number; cents: number } => {
const scale = getCurrencyScale(m);
const whole = Math.trunc(m.amount / scale);
const cents = m.amount - whole * scale;
return { whole, cents };
};
// ------ Formatting ------ //
export const format = (
m: Money,
ops?: {
/**
* - true: always show cents
* - "no": never show cents
* - false | "ifAny": show cents only if they are not zero
*/
cents?: boolean | "ifAny" | "no"; // default: true; if false, 00 cents will be omitted
locale?: string;
trailingZeros?: boolean; // default: true; if false, 1.50 will be formatted as 1.5
withPlusSign?: boolean; // default: false; if true, positive numbers will be prefixed with a plus sign
}
): string => {
const { cents, locale, trailingZeros, withPlusSign } = {
cents: ops?.cents ?? true,
locale: ops?.locale ?? config.defaultLocale,
trailingZeros: ops?.trailingZeros ?? true,
withPlusSign: ops?.withPlusSign ?? false,
};
const parts = formatParts(m, locale);
const signSymbol = parts.sign === "-" ? "-" : "";
let formatted = "";
if (
(!cents || cents === "ifAny") &&
parts.cents === "0".repeat(getCurrency(m.currency).precision)
) {
formatted = `${signSymbol}${parts.currencySymbol}${parts.wholeFormatted}`;
} else if (cents === "no") {
formatted = `${signSymbol}${parts.currencySymbol}${parts.wholeFormatted}`;
} else {
formatted = `${signSymbol}${parts.currencySymbol}${parts.wholeFormatted}${parts.decimalSeparator}${parts.cents}`;
}
if (!trailingZeros) {
formatted = formatted.replace(/0+$/, "");
}
if (withPlusSign && parts.sign === "+") {
formatted = `+${formatted}`;
}
return formatted;
};
export const formatIntegerPart = (
integerPart: number,
locale = config.defaultLocale
) => {
return new Intl.NumberFormat(locale).format(integerPart);
};
export const formatParts = (
m: Money,
locale = config.defaultLocale
): {
whole: string;
wholeFormatted: string;
cents: string;
currencySymbol: string;
decimalSeparator: string;
sign: "+" | "-" | "";
} => {
const { symbol, precision } = getCurrency(m.currency);
const { decimalSeparator } = getLocale(locale);
const { whole, cents } = split(m);
const absWhole = Math.abs(whole);
const wholeFormatted = formatIntegerPart(absWhole, locale);
const sign = getAmountSign(m);
return {
whole: `${absWhole}`,
wholeFormatted,
cents: `${Math.abs(cents)}`.padStart(precision, "0"),
currencySymbol: symbol,
decimalSeparator,
sign,
};
};
// ------ Parsing ------ //
export const parse = (
s: string,
currency: string,
locale = config.defaultLocale,
decimalSeparator?: "." | ","
): Money => {
const _decimalSeparator =
decimalSeparator ?? getLocale(locale).decimalSeparator;
const amountFloatString = {
",": () =>
s
.replace(/[^0-9.,]/g, "")
.replace(/\./g, "")
.replace(/\,/g, "."),
".": () => s.replace(/[^0-9.,]/g, "").replace(/\,/g, ""),
}[_decimalSeparator]();
const parsedFloat = parseFloat(amountFloatString);
const amountFloat = Number.isNaN(parsedFloat) ? 0 : parsedFloat;
return fromFloat(amountFloat, currency);
};
// ------ Helper methods ------ //
const getCurrencyScale = (m: Money): number => {
return 10 ** getCurrency(m.currency).precision;
};
const getAmountSign = (m: Money) => {
if (m.amount > 0) {
return "+";
}
if (m.amount < 0) {
return "-";
}
return "";
};