@fin.cx/calculation

Financial math for JavaScript and TypeScript with deterministic decimal arithmetic. @fin.cx/calculation wraps decimal.js in focused classes for precision arithmetic, time-value-of-money formulas, interest calculations, amortization schedules, and currency formatting/conversion.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Why It Exists

JavaScript numbers are binary floating-point values, which is not what you want for money:

0.1 + 0.2; // 0.30000000000000004

This package keeps calculations in Decimal values so financial code can avoid accidental floating-point drift:

import { Calculator } from '@fin.cx/calculation';

const calc = new Calculator();
calc.add(0.1, 0.2).toString(); // '0.3'

What You Get

  • Calculator: high-precision arithmetic helpers around decimal.js
  • Financial: present value, future value, payments, NPV, IRR, MIRR, XIRR, periods, and rates
  • Interest: simple, compound, continuous, nominal, effective, real, periodic, APY, and doubling-time calculations
  • Amortization: loan schedules, remaining balances, payoff dates, interest savings, LTV, DTI, and capacity calculations
  • Currency: currency registration, formatting, parsing, exchange-rate conversion, money objects, discounts, tax, and percentages
  • currencyMinorUnits: the decimals of every currency of ISO 4217 List One, failing closed for a code the list does not give them for
  • creditorReferenceOf, isCreditorReference, normalizeCreditorReference, formatCreditorReference: the ISO 11649 creditor reference (RF…), and mod97, ISO 7064 MOD 97-10 as the IBAN uses it
  • Browser and Node.js support through ESM TypeScript sources and bundled output

Installation

pnpm add @fin.cx/calculation

Quick Start

import { Amortization, Calculator, Currency, Financial, Interest } from '@fin.cx/calculation';

const calc = new Calculator({ precision: 20 });
const exactSum = calc.add(0.1, 0.2);
console.log(exactSum.toString()); // '0.3'

const financial = new Financial();
const monthlyPayment = financial.payment(200000, 0.045 / 12, 360);
console.log(monthlyPayment.toFixed(2)); // '1013.37'

const interest = new Interest();
const monthlyCompoundInterest = interest.compound(1000, 0.12, 1, 'monthly');
console.log(monthlyCompoundInterest.toFixed(2)); // '126.83'

const amortization = new Amortization();
const schedule = amortization.schedule({
  principal: 250000,
  annualRate: 0.045,
  termYears: 30,
  extraPayment: 200,
});
console.log(schedule.payments.length);

const currency = new Currency();
currency.setExchangeRate('USD', 'EUR', 0.85);
console.log(currency.format(currency.convert(100, 'USD', 'EUR'), 'EUR')); // '€85,00'

Precision Model

All calculation methods return Decimal instances unless documented otherwise. Convert at the edge of your application with toString(), toNumber(), toFixed(), or the helpers on Calculator.

Calculator passes its precision option to decimal.js, where precision means significant digits. Each calculator constructor configures the shared Decimal constructor, so create calculators with the precision you want before running a batch of calculations.

import { Calculator } from '@fin.cx/calculation';

const calc = new Calculator({ precision: 10 });

calc.add(10.5, 20.3).toString(); // '30.8'
calc.subtract(100, 45.5).toString(); // '54.5'
calc.multiply(15, 3.5).toString(); // '52.5'
calc.divide(1, 3).toString(); // '0.3333333333'
calc.power(2, 8).toString(); // '256'
calc.sqrt(16).toString(); // '4'
calc.round(3.14159, 2).toString(); // '3.14'

Financial Calculations

import { Financial } from '@fin.cx/calculation';

const financial = new Financial();

const presentValue = financial.presentValue(10000, 0.05, 5);
const futureValue = financial.futureValue(5000, 0.07, 10);
const payment = financial.payment(200000, 0.045 / 12, 360);
const cashFlows = [-50000, 15000, 15000, 15000, 15000, 20000];
const npv = financial.npv(0.1, cashFlows);
const irr = financial.irr(cashFlows);
const mirr = financial.mirr(cashFlows, 0.08, 0.1);

console.log({
  presentValue: presentValue.toFixed(2),
  futureValue: futureValue.toFixed(2),
  payment: payment.toFixed(2),
  npv: npv.toFixed(2),
  irr: irr.toFixed(4),
  mirr: mirr.toFixed(4),
});

The Financial class includes these methods:

  • presentValue(futureValue, rate, periods)
  • futureValue(presentValue, rate, periods)
  • payment(principal, rate, periods)
  • npv(rate, cashFlows)
  • irr(cashFlows, guess?, tolerance?, maxIterations?)
  • mirr(cashFlows, financeRate, reinvestRate)
  • periods(presentValue, futureValue, rate)
  • rate(presentValue, futureValue, periods)
  • xirr(cashFlows, dates, guess?)

Interest Calculations

import { Interest } from '@fin.cx/calculation';

const interest = new Interest();

interest.simple(1000, 0.05, 2).toString(); // '100'
interest.simpleAmount(1000, 0.05, 2).toString(); // '1100'
interest.compound(1000, 0.05, 2, 'annually').toFixed(2); // '102.50'
interest.compound(1000, 0.12, 1, 'monthly').toFixed(2); // '126.83'
interest.effectiveAnnualRate(0.12, 'monthly').toFixed(4); // '0.1268'
interest.realRate(0.08, 0.03).toFixed(4); // '0.0485'
interest.ruleOf72(8).toString(); // '9'

Supported compounding frequencies are annually, semiannually, quarterly, monthly, weekly, daily, and continuous.

Amortization Schedules

import { Amortization } from '@fin.cx/calculation';

const amortization = new Amortization();

const schedule = amortization.schedule({
  principal: 250000,
  annualRate: 0.045,
  termYears: 30,
  extraPayment: 200,
});

console.log(schedule.monthlyPayment.toFixed(2));
console.log(schedule.totalInterest.toFixed(2));
console.log(schedule.payments[0]);

Loan options accept any decimal.js value-compatible input, including numbers, strings, and Decimal instances:

type DecimalInput = number | string;

interface ILoanOptions {
  principal: DecimalInput;
  annualRate: DecimalInput;
  termYears?: DecimalInput;
  termMonths?: DecimalInput;
  paymentFrequency?: 'monthly' | 'biweekly' | 'weekly';
  extraPayment?: DecimalInput;
}

Schedule entries contain period, payment, principal, interest, balance, cumulativePrincipal, and cumulativeInterest as Decimal values where applicable.

Currency Formatting And Conversion

import { Currency } from '@fin.cx/calculation';

const currency = new Currency();

currency.setExchangeRates([
  { from: 'USD', to: 'EUR', rate: 0.85 },
  { from: 'USD', to: 'GBP', rate: 0.73 },
]);

const euros = currency.convert(1000, 'USD', 'EUR');
currency.format(euros, 'EUR'); // '€850,00'
currency.parse('$1,234.56', 'USD').toString(); // '1234.56'

const first = currency.money(100, 'USD');
const second = currency.money(50, 'USD');
const total = currency.addMoney(first, second);
console.log(currency.format(total.amount, total.currency)); // '$150.00'

currency.discount(100, 20).toString(); // '80'
currency.withTax(100, 0.08).toString(); // '108'

Custom currencies can be registered at runtime:

currency.registerCurrency({
  code: 'BTC',
  symbol: '₿',
  decimals: 8,
  thousandsSeparator: ',',
  decimalSeparator: '.',
  symbolPosition: 'before',
});

currency.format(0.00042, 'BTC'); // '₿0.00042000'

Currency Minor Units (ISO 4217)

import { allocateCents, currencyMinorUnits, centsFitMinorUnits, CurrencyMinorUnitsError, minorUnitStepCents, roundCentsToMinorUnits, splitGrossCents, vatFromNetCents } from '@fin.cx/calculation';

currencyMinorUnits('EUR'); // 2
currencyMinorUnits('JPY'); // 0
currencyMinorUnits('BHD'); // 3
currencyMinorUnits('HUF'); // 2, although locale data often shows forints without decimals
currencyMinorUnits('HRK'); // 2: withdrawn in 2023, as List One gave it before

// cents are hundredths of the unit for every currency, as centsFromString reads them
centsFitMinorUnits(123400, 'JPY'); // true: 1 234 yen
centsFitMinorUnits(123450, 'JPY'); // false: the yen has no minor unit

// a computed amount rounded once, half up, to its currency's minor units
minorUnitStepCents('JPY'); // 100: one yen is a hundred hundredths
roundCentsToMinorUnits(19019, 'JPY'); // 19000: 190,19 yen are 190 yen
roundCentsToMinorUnits('19049.5', 'JPY'); // 19000: 190,495 yen, rounded once, not first to a hundredth
vatFromNetCents(100100, '19', 'JPY'); // 19000: the VAT of 1 001 yen in whole yen
splitGrossCents(119100, '19', 'JPY'); // { netCents: 100100, vatCents: 19000 }
allocateCents(100000, [1, 1, 1], 'JPY'); // [33400, 33300, 33300]: 1 000 yen in whole yen, adding up

try {
  currencyMinorUnits('DEM'); // withdrawn in 2002, before any readable edition of the list
} catch (error) {
  if (error instanceof CurrencyMinorUnitsError) {
    error.code; // 'unknown_currency'; 'no_minor_unit' for a listed code without one, such as XAU
  }
}

Rounding to a currency's minor units. roundCentsToMinorUnits, and vatFromNetCents, splitGrossCents and allocateCents given a currency, compute in the currency's grain: whole yen for JPY, hundredths for a currency of two decimals or more (a third decimal, as of BHD, is not carried by cents). Without a currency the three compute in hundredths exactly as before. The rounding is the package's one rule, half up (away from zero on a half, kaufmännisches Runden), applied once to the exact value; allocateCents keeps the largest-remainder method, so the shares add up to the total. An amount given in must itself be an amount of the currency: a net (vatFromNetCents), a gross (splitGrossCents) or a total (allocateCents) between its minor units, such as 1 191,50 yen, is refused, so the VAT a split leaves is always one as well. A rounded zero is 0, never -0. A code not on ISO 4217, or one without minor units, fails closed as currencyMinorUnits does. What this does not model: the rounding rules a country sets for its own currency or its taxes, such as a rounding of cash payments or of the tax on an invoice as a whole. For a German book the figures that count are in euros: § 16 Abs. 6 UStG converts an amount in another currency to euros, and that conversion rounds in cents.

The decimals come from ISO 4217 List One, "Current currency & funds code list", as published on 2026-09-17 (ISO_4217_PUBLISHED) by SIX, the ISO 4217 maintenance agency: https://www.six-group.com/en/products-services/financial-information/market-reference-data/data-standards.html (the file: https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml). ISO_4217_MINOR_UNITS holds every alphabetic code of that list, null where the list gives "N.A.". The ISO list, not the locale data of Intl, is the source: CLDR shows HUF, IDR, LAK and LBP without decimals and IQD with none, where ISO 4217 gives them two and three. There is no default: a code on neither list (a made-up code, a lower-case code, a code withdrawn before 2013, see below) throws CurrencyMinorUnitsError with unknown_currency, and a listed code without a minor unit throws it with no_minor_unit.

Withdrawn codes. An old document can be in a currency ISO 4217 has since withdrawn: a Croatian invoice of 2022 in kuna (HRK). ISO_4217_WITHDRAWN_MINOR_UNITS holds the codes of List Three ("Historic denominations", published 2026-01-01 as ISO_4217_LIST_THREE_PUBLISHED, https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-three.xml), which gives each withdrawal date but no minor unit, with the minor unit the last List One edition that carried the code and can still be read gave it. currencyMinorUnits('HRK') is 2, and iso4217StatusOf says whether a code is current, withdrawn or unknown. The editions, as the Internet Archive kept the list files of SIX and of its predecessor site currency-iso.org:

Unconfirmed, not included: the 115 codes List Three names that were withdrawn before the earliest List One file that can be read (2013); no readable edition gives their minor unit, so they are unknown and fail closed: ADP, AFA, ALK, AOK, AON, AOR, ARA, ARP, ARY, ATS, AYM, AZM, BAD, BEC, BEF, BEL, BGJ, BGK, BGL, BOP, BRB, BRC, BRE, BRN, BRR, BUK, BYB, CHC, CSD, CSJ, CSK, CYP, DDM, DEM, ECS, ECV, EEK, ESA, ESB, ESP, FIM, FRF, GEK, GHC, GHP, GNE, GNS, GQE, GRD, GWE, GWP, HRD, IEP, ILP, ILR, ISJ, ITL, LAJ, LSM, LTT, LUC, LUF, LUL, LVR, MGF, MLF, MTL, MTP, MVQ, MXP, MZE, MZM, NIC, NLG, PEH, PEI, PES, PLZ, PTE, RHD, ROK, ROL, RUR, SDD, SDP, SIT, SKK, SRG, SUR, TJR, TMM, TPE, TRL, UAK, UGS, UGW, UYN, UYP, VEB, VNC, XEU, XFO, XRE, YDD, YUD, YUM, YUN, ZAL, ZMK, ZRN, ZRZ, ZWC, ZWD, ZWN, ZWR.

Creditor Reference (ISO 11649)

import { CreditorReferenceError, creditorReferenceOf, formatCreditorReference, isCreditorReference, mod97, normalizeCreditorReference } from '@fin.cx/calculation';

creditorReferenceOf('539007547034'); // 'RF18539007547034', the example of ISO 11649
formatCreditorReference('RF18539007547034'); // 'RF18 5390 0754 7034', the print form
isCreditorReference('rf18 5390 0754 7034'); // true: as a payer types it
normalizeCreditorReference('RF19 5390 0754 7034'); // null: wrong check digits
mod97('WEST12345698765432GB82'); // 1: the IBAN GB82 WEST 1234 5698 7654 32, rearranged

try {
  creditorReferenceOf('RE-2026-0042');
} catch (error) {
  if (error instanceof CreditorReferenceError) {
    error.code; // 'invalid_reference'; 'reference_too_long' beyond 21 characters
  }
}

A creditor reference is RF, two check digits and a reference of 1 to 21 letters and digits, 25 characters at most. A payer quotes it with a transfer, and in a SEPA credit transfer it travels as the structured remittance (the EPC QR code's line 10), so the payee finds the payment by it. The check digits are those of ISO 7064 MOD 97-10: the reference followed by RF00, each letter read as 10 (A) to 35 (Z), taken modulo 97, and 98 less the remainder; a reference is valid when its first four characters moved to the end give 1.

creditorReferenceOf removes whitespace and takes letters in upper case; it refuses anything else (invalid_reference: an empty reference, a hyphen, an umlaut, and a character upper case would fold into letters A–Z, such as ß, ſ, ı or fi, which is tested before anything is upper-cased) and a reference longer than 21 characters (reference_too_long) with a typed CreditorReferenceError. It cuts or replaces nothing: which characters of an invoice number make its reference is the caller's decision. normalizeCreditorReference gives the electronic form of a valid reference (upper case, no spaces) or null, and isCreditorReference says whether it is valid. Check digits 00, 01 and 99 are refused: ISO 7064 MOD 97-10 gives 02 to 98 only, and 01 leaves the same remainder as 98, so a reference whose check digits are 98 has one valid form.

Sources: the example RF18 5390 0754 7034 and its form of 25 characters RF18000000000539007547034 (https://en.wikipedia.org/wiki/Creditor_Reference); a selection of the valid references in python-stdnum's tests (https://github.com/arthurdejong/python-stdnum/blob/master/tests/test_iso11649.doctest), which this implementation validates and reproduces from the reference alone; the IBAN of ISO 13616's example for mod97. Unconfirmed: the text of ISO 11649 itself is sold by ISO and was not read; the refusal of the check digits 00, 01 and 99 follows ISO 7064 MOD 97-10, not a rule quoted from ISO 11649.

TypeScript Interfaces

Core interfaces are exported from the package:

import type {
  CompoundingFrequency,
  ICashFlow,
  IAmortizationPayment,
  IAmortizationSchedule,
  ICalculatorOptions,
  ICurrencyOptions,
  IExchangeRate,
  IInterestOptions,
  ILoanOptions,
  IMoneyValue,
} from '@fin.cx/calculation';

Development

pnpm install
pnpm test
pnpm run build

The test suite runs the shared test file in Node.js and Chromium, matching the package's cross-platform target.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
a unified calculation module to always get to the exact same result.
Readme
664 KiB
Languages
TypeScript 100%