turash/bugulma/frontend/lib/fin/index.ts
2025-12-15 10:06:41 +01:00

113 lines
3.1 KiB
TypeScript

// Generic finance utilities for formatting and arithmetic
export type Currency = string; // ISO 4217, e.g. 'EUR', 'USD'
export interface FormatOptions {
locale?: string; // defaults to 'en-US'
currency?: Currency; // defaults to 'EUR'
minimumFractionDigits?: number;
maximumFractionDigits?: number;
}
const DEFAULT_LOCALE = 'en-US';
const DEFAULT_CURRENCY: Currency = 'EUR';
export const formatNumber = (value: number, locale = DEFAULT_LOCALE) => {
return new Intl.NumberFormat(locale).format(value);
};
export const formatCurrency = (value: number | bigint, opts?: Partial<FormatOptions>) => {
const locale = opts?.locale ?? DEFAULT_LOCALE;
const currency = opts?.currency ?? DEFAULT_CURRENCY;
const minimumFractionDigits = opts?.minimumFractionDigits ?? 0;
const maximumFractionDigits = opts?.maximumFractionDigits ?? 0;
// If passed a bigint, treat it as cents and convert to major units if maximumFractionDigits>0
let numericValue: number;
if (typeof value === 'bigint') {
numericValue = Number(value) / Math.pow(10, maximumFractionDigits);
} else {
numericValue = value;
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
maximumFractionDigits,
}).format(numericValue);
};
// A small helper to avoid floating point rounding issues by using integer cents
export class Money {
// internal representation: cents for a 2-decimal currency by default
private cents: bigint;
private readonly scale: number; // number of fraction digits (e.g. 2 for cents)
constructor(amount: number | bigint, scale = 2) {
this.scale = scale;
if (typeof amount === 'bigint') {
this.cents = amount;
} else {
// convert float to scaled integer
const multiplier = Math.pow(10, this.scale);
this.cents = BigInt(Math.round(amount * multiplier));
}
}
static fromCents(cents: bigint, scale = 2) {
const m = new Money(0, scale);
m.cents = cents;
return m;
}
add(other: Money) {
this._assertSameScale(other);
return Money.fromCents(this.cents + other.cents, this.scale);
}
sub(other: Money) {
this._assertSameScale(other);
return Money.fromCents(this.cents - other.cents, this.scale);
}
mul(factor: number) {
const result = Number(this.cents) * factor;
return Money.fromCents(BigInt(Math.round(result)), this.scale);
}
div(divisor: number) {
const result = Number(this.cents) / divisor;
return Money.fromCents(BigInt(Math.round(result)), this.scale);
}
toNumber() {
return Number(this.cents) / Math.pow(10, this.scale);
}
toCents(): bigint {
return this.cents;
}
format(opts?: Partial<FormatOptions>) {
const maximumFractionDigits = opts?.maximumFractionDigits ?? this.scale;
return formatCurrency(this.cents, {
locale: opts?.locale,
currency: opts?.currency,
minimumFractionDigits: opts?.minimumFractionDigits ?? this.scale,
maximumFractionDigits,
});
}
private _assertSameScale(other: Money) {
if (other.scale !== this.scale) {
throw new Error('Money scale mismatch');
}
}
}
export default {
formatCurrency,
formatNumber,
Money,
};