impl/typescript.ts
3,440 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type RoundingMode, roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
/** A hundred years of monthly payments, sixty of weekly ones. */
export const MAX_PAYMENTS = 3000;
/**
* The number of payments a term holds, after checking the arguments every
* lending capability shares.
*/
export function paymentCount(annualRateBasisPoints: number, termMonths: number, paymentsPerYear: number): number {
if (!Number.isInteger(annualRateBasisPoints) || annualRateBasisPoints < 0 || annualRateBasisPoints > 100000) {
throw new RangeError(`annualRateBasisPoints must be between 0 and 100000, received ${annualRateBasisPoints}`);
}
if (!Number.isInteger(paymentsPerYear) || paymentsPerYear < 1 || paymentsPerYear > 52) {
throw new RangeError(`paymentsPerYear must be between 1 and 52, received ${paymentsPerYear}`);
}
if (!Number.isInteger(termMonths) || termMonths < 1) {
throw new RangeError(`termMonths must be at least 1, received ${termMonths}`);
}
if ((termMonths * paymentsPerYear) % 12 !== 0) {
throw new RangeError(`a term of ${termMonths} months is not a whole number of payments at ${paymentsPerYear} a year`);
}
const n = (termMonths * paymentsPerYear) / 12;
if (n > MAX_PAYMENTS) {
throw new RangeError(`${n} payments is more than the ${MAX_PAYMENTS} payment limit`);
}
return n;
}
/**
* Round the exact quotient numerator / denominator (both positive) with a
* math.round-div mode, however large they are. Only the integer part and
* where the remainder sits against one half matter, so the decision is handed
* to roundDiv as a small fraction with the same integer part and the same
* side of the half: q, q + 1/4, q + 1/2 or q + 3/4.
*/
export function roundWide(numerator: bigint, denominator: bigint, mode: RoundingMode): number {
const q = numerator / denominator;
const twice = (numerator % denominator) * 2n;
const small = Number(q);
if (twice === 0n) return roundDiv(small, 1, mode);
if (twice === denominator) return roundDiv(2 * small + 1, 2, mode);
return roundDiv(4 * small + (twice < denominator ? 1 : 3), 4, mode);
}
/**
* The level payment that repays `principal` over the term at a nominal
* annual rate divided equally between the periods:
*
* payment = P · r / (1 − (1 + r)^−n), r = rate / paymentsPerYear
*
* With r = b / D (b basis points, D = 10000 × paymentsPerYear) this is the
* exact fraction P · b · (D + b)^n / (D · ((D + b)^n − D^n)), evaluated in
* integers of whatever size it takes and rounded once. A zero rate is P / n.
*/
export function loanPayment(
principal: Money,
annualRateBasisPoints: number,
termMonths: number,
paymentsPerYear: number,
mode: RoundingMode,
): Money {
if (!Number.isInteger(principal.minor) || principal.minor <= 0) {
throw new RangeError(`principal must be greater than zero, received ${principal.minor}`);
}
const n = paymentCount(annualRateBasisPoints, termMonths, paymentsPerYear);
if (annualRateBasisPoints === 0) {
return money(roundDiv(principal.minor, n, mode), principal.currency);
}
const b = BigInt(annualRateBasisPoints);
const d = 10000n * BigInt(paymentsPerYear);
const grown = (d + b) ** BigInt(n);
const numerator = BigInt(principal.minor) * b * grown;
const denominator = d * (grown - d ** BigInt(n));
return money(roundWide(numerator, denominator, mode), principal.currency);
}