feat(core): initial release of financial calculation package with decimal precision
Some checks failed
CI Pipeline (nottags) / security (push) Successful in 17s
CI Pipeline (tags) / security (push) Successful in 17s
CI Pipeline (nottags) / test (push) Failing after 52s
CI Pipeline (tags) / test (push) Failing after 50s
CI Pipeline (tags) / release (push) Has been skipped
CI Pipeline (tags) / metadata (push) Has been skipped
Some checks failed
CI Pipeline (nottags) / security (push) Successful in 17s
CI Pipeline (tags) / security (push) Successful in 17s
CI Pipeline (nottags) / test (push) Failing after 52s
CI Pipeline (tags) / test (push) Failing after 50s
CI Pipeline (tags) / release (push) Has been skipped
CI Pipeline (tags) / metadata (push) Has been skipped
This commit is contained in:
328
ts/calculation.classes.currency.ts
Normal file
328
ts/calculation.classes.currency.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import * as plugins from './calculation.plugins.js';
|
||||
import { Calculator } from './calculation.classes.calculator.js';
|
||||
|
||||
export interface ICurrencyOptions {
|
||||
code: string;
|
||||
symbol?: string;
|
||||
decimals?: number;
|
||||
thousandsSeparator?: string;
|
||||
decimalSeparator?: string;
|
||||
symbolPosition?: 'before' | 'after';
|
||||
spaceBetweenSymbolAndValue?: boolean;
|
||||
}
|
||||
|
||||
export interface IExchangeRate {
|
||||
from: string;
|
||||
to: string;
|
||||
rate: plugins.Decimal.Value;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface IMoneyValue {
|
||||
amount: plugins.Decimal;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency calculations and formatting class
|
||||
*/
|
||||
export class Currency extends Calculator {
|
||||
private currencies: Map<string, ICurrencyOptions> = new Map();
|
||||
private exchangeRates: Map<string, plugins.Decimal> = new Map();
|
||||
|
||||
constructor() {
|
||||
super({ precision: 10 });
|
||||
this.initializeCommonCurrencies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize common currencies with their default options
|
||||
*/
|
||||
private initializeCommonCurrencies(): void {
|
||||
const commonCurrencies: ICurrencyOptions[] = [
|
||||
{ code: 'USD', symbol: '$', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'EUR', symbol: '€', decimals: 2, thousandsSeparator: '.', decimalSeparator: ',', symbolPosition: 'before' },
|
||||
{ code: 'GBP', symbol: '£', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'JPY', symbol: '¥', decimals: 0, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'CHF', symbol: 'Fr.', decimals: 2, thousandsSeparator: "'", decimalSeparator: '.', symbolPosition: 'after', spaceBetweenSymbolAndValue: true },
|
||||
{ code: 'CAD', symbol: 'C$', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'AUD', symbol: 'A$', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'CNY', symbol: '¥', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'INR', symbol: '₹', decimals: 2, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' },
|
||||
{ code: 'BTC', symbol: '₿', decimals: 8, thousandsSeparator: ',', decimalSeparator: '.', symbolPosition: 'before' }
|
||||
];
|
||||
|
||||
commonCurrencies.forEach(currency => {
|
||||
this.currencies.set(currency.code, currency);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new currency or update existing one
|
||||
*/
|
||||
public registerCurrency(options: ICurrencyOptions): void {
|
||||
this.currencies.set(options.code, {
|
||||
...options,
|
||||
decimals: options.decimals ?? 2,
|
||||
thousandsSeparator: options.thousandsSeparator ?? ',',
|
||||
decimalSeparator: options.decimalSeparator ?? '.',
|
||||
symbolPosition: options.symbolPosition ?? 'before',
|
||||
spaceBetweenSymbolAndValue: options.spaceBetweenSymbolAndValue ?? false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set exchange rate between two currencies
|
||||
* @param from Source currency code
|
||||
* @param to Target currency code
|
||||
* @param rate Exchange rate (how many 'to' units per 'from' unit)
|
||||
*/
|
||||
public setExchangeRate(from: string, to: string, rate: plugins.Decimal.Value): void {
|
||||
const key = `${from}_${to}`;
|
||||
this.exchangeRates.set(key, this.decimal(rate));
|
||||
|
||||
// Also set the inverse rate
|
||||
const inverseKey = `${to}_${from}`;
|
||||
const inverseRate = this.divide(1, rate);
|
||||
this.exchangeRates.set(inverseKey, inverseRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple exchange rates at once
|
||||
*/
|
||||
public setExchangeRates(rates: IExchangeRate[]): void {
|
||||
rates.forEach(rate => {
|
||||
this.setExchangeRate(rate.from, rate.to, rate.rate);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert amount from one currency to another
|
||||
* @param amount The amount to convert
|
||||
* @param from Source currency code
|
||||
* @param to Target currency code
|
||||
*/
|
||||
public convert(
|
||||
amount: plugins.Decimal.Value,
|
||||
from: string,
|
||||
to: string
|
||||
): plugins.Decimal {
|
||||
if (from === to) {
|
||||
return this.decimal(amount);
|
||||
}
|
||||
|
||||
const key = `${from}_${to}`;
|
||||
const rate = this.exchangeRates.get(key);
|
||||
|
||||
if (!rate) {
|
||||
throw new Error(`Exchange rate not found for ${from} to ${to}`);
|
||||
}
|
||||
|
||||
return this.multiply(amount, rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a money value object
|
||||
*/
|
||||
public money(amount: plugins.Decimal.Value, currency: string): IMoneyValue {
|
||||
return {
|
||||
amount: this.decimal(amount),
|
||||
currency
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add two money values (must be same currency)
|
||||
*/
|
||||
public addMoney(money1: IMoneyValue, money2: IMoneyValue): IMoneyValue {
|
||||
if (money1.currency !== money2.currency) {
|
||||
throw new Error('Cannot add money values with different currencies');
|
||||
}
|
||||
|
||||
return {
|
||||
amount: this.add(money1.amount, money2.amount),
|
||||
currency: money1.currency
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract two money values (must be same currency)
|
||||
*/
|
||||
public subtractMoney(money1: IMoneyValue, money2: IMoneyValue): IMoneyValue {
|
||||
if (money1.currency !== money2.currency) {
|
||||
throw new Error('Cannot subtract money values with different currencies');
|
||||
}
|
||||
|
||||
return {
|
||||
amount: this.subtract(money1.amount, money2.amount),
|
||||
currency: money1.currency
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency value for display
|
||||
* @param amount The amount to format
|
||||
* @param currencyCode The currency code
|
||||
* @param options Override formatting options
|
||||
*/
|
||||
public format(
|
||||
amount: plugins.Decimal.Value,
|
||||
currencyCode: string,
|
||||
options?: Partial<ICurrencyOptions>
|
||||
): string {
|
||||
const currency = this.currencies.get(currencyCode);
|
||||
|
||||
if (!currency) {
|
||||
throw new Error(`Currency ${currencyCode} not registered`);
|
||||
}
|
||||
|
||||
const formatOptions = { ...currency, ...options };
|
||||
const value = this.decimal(amount);
|
||||
|
||||
// Round to the correct number of decimal places
|
||||
const rounded = this.round(value, formatOptions.decimals || 0);
|
||||
|
||||
// Convert to string with fixed decimals
|
||||
let numberStr = this.toFixed(rounded, formatOptions.decimals || 0);
|
||||
|
||||
// Split into integer and decimal parts
|
||||
const parts = numberStr.split('.');
|
||||
|
||||
// Apply thousands separator to integer part
|
||||
if (formatOptions.thousandsSeparator) {
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, formatOptions.thousandsSeparator);
|
||||
}
|
||||
|
||||
// Join parts with appropriate decimal separator
|
||||
if (parts.length === 2) {
|
||||
numberStr = parts.join(formatOptions.decimalSeparator || '.');
|
||||
} else {
|
||||
numberStr = parts[0];
|
||||
}
|
||||
|
||||
// Add currency symbol
|
||||
if (formatOptions.symbol) {
|
||||
const space = formatOptions.spaceBetweenSymbolAndValue ? ' ' : '';
|
||||
|
||||
if (formatOptions.symbolPosition === 'after') {
|
||||
return `${numberStr}${space}${formatOptions.symbol}`;
|
||||
} else {
|
||||
return `${formatOptions.symbol}${space}${numberStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
return numberStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a formatted currency string back to a decimal value
|
||||
* @param formattedValue The formatted string
|
||||
* @param currencyCode The currency code
|
||||
*/
|
||||
public parse(formattedValue: string, currencyCode: string): plugins.Decimal {
|
||||
const currency = this.currencies.get(currencyCode);
|
||||
|
||||
if (!currency) {
|
||||
throw new Error(`Currency ${currencyCode} not registered`);
|
||||
}
|
||||
|
||||
// Remove currency symbol
|
||||
let cleanValue = formattedValue;
|
||||
if (currency.symbol) {
|
||||
cleanValue = cleanValue.replace(currency.symbol, '');
|
||||
}
|
||||
|
||||
// Remove thousands separator
|
||||
if (currency.thousandsSeparator) {
|
||||
cleanValue = cleanValue.replace(new RegExp(`\\${currency.thousandsSeparator}`, 'g'), '');
|
||||
}
|
||||
|
||||
// Replace decimal separator with standard dot
|
||||
if (currency.decimalSeparator && currency.decimalSeparator !== '.') {
|
||||
cleanValue = cleanValue.replace(currency.decimalSeparator, '.');
|
||||
}
|
||||
|
||||
// Remove any remaining spaces and parse
|
||||
cleanValue = cleanValue.trim();
|
||||
|
||||
return this.decimal(cleanValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round amount to currency's standard decimal places
|
||||
*/
|
||||
public roundToCurrency(amount: plugins.Decimal.Value, currencyCode: string): plugins.Decimal {
|
||||
const currency = this.currencies.get(currencyCode);
|
||||
|
||||
if (!currency) {
|
||||
throw new Error(`Currency ${currencyCode} not registered`);
|
||||
}
|
||||
|
||||
return this.round(amount, currency.decimals || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage of amount
|
||||
* @param amount The base amount
|
||||
* @param percentage The percentage (e.g., 15 for 15%)
|
||||
*/
|
||||
public percentage(amount: plugins.Decimal.Value, percentage: plugins.Decimal.Value): plugins.Decimal {
|
||||
const percent = this.divide(percentage, 100);
|
||||
return this.multiply(amount, percent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate markup amount
|
||||
* @param cost The base cost
|
||||
* @param markupPercentage The markup percentage
|
||||
*/
|
||||
public markup(cost: plugins.Decimal.Value, markupPercentage: plugins.Decimal.Value): plugins.Decimal {
|
||||
const markupAmount = this.percentage(cost, markupPercentage);
|
||||
return this.add(cost, markupAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate discount amount
|
||||
* @param price The original price
|
||||
* @param discountPercentage The discount percentage
|
||||
*/
|
||||
public discount(price: plugins.Decimal.Value, discountPercentage: plugins.Decimal.Value): plugins.Decimal {
|
||||
const discountAmount = this.percentage(price, discountPercentage);
|
||||
return this.subtract(price, discountAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax amount
|
||||
* @param amount The base amount
|
||||
* @param taxRate The tax rate (e.g., 0.08 for 8%)
|
||||
*/
|
||||
public tax(amount: plugins.Decimal.Value, taxRate: plugins.Decimal.Value): plugins.Decimal {
|
||||
return this.multiply(amount, taxRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total with tax
|
||||
* @param amount The base amount
|
||||
* @param taxRate The tax rate
|
||||
*/
|
||||
public withTax(amount: plugins.Decimal.Value, taxRate: plugins.Decimal.Value): plugins.Decimal {
|
||||
const taxAmount = this.tax(amount, taxRate);
|
||||
return this.add(amount, taxAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base amount from a tax-inclusive price
|
||||
* @param totalAmount The total amount including tax
|
||||
* @param taxRate The tax rate
|
||||
*/
|
||||
public extractTax(totalAmount: plugins.Decimal.Value, taxRate: plugins.Decimal.Value): {
|
||||
baseAmount: plugins.Decimal;
|
||||
taxAmount: plugins.Decimal;
|
||||
} {
|
||||
const divisor = this.add(1, taxRate);
|
||||
const baseAmount = this.divide(totalAmount, divisor);
|
||||
const taxAmount = this.subtract(totalAmount, baseAmount);
|
||||
|
||||
return { baseAmount, taxAmount };
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user