/* This file is part of GNU Taler (C) 2019 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * Types and helper functions for dealing with Taler amounts. */ /** * Imports. */ import { Codec, Context, DecodingError, buildCodecForObject, codecForNumber, codecForString, renderContext, } from "./codec.js"; import { CurrencySpecification } from "./index.js"; import { AmountString } from "./taler-types.js"; /** * Number of fractional units that one value unit represents. */ export const amountFractionalBase = 1e8; /** * How many digits behind the comma are required to represent the * fractional value in human readable decimal format? Must match * lg(fractionalBase) */ export const amountFractionalLength = 8; /** * Maximum allowed value field of an amount. */ export const amountMaxValue = 2 ** 52; /** * Separator character between integer and fractional */ export const FRAC_SEPARATOR = "."; /** * Non-negative financial amount. Fractional values are expressed as multiples * of 1e-8. */ export interface AmountJson { /** * Value, must be an integer. */ readonly value: number; /** * Fraction, must be an integer. Represent 1/1e8 of a unit. */ readonly fraction: number; /** * Currency of the amount. */ readonly currency: string; } /** * Immutable amount. */ export class Amount { static from(a: AmountLike): Amount { return new Amount(Amounts.parseOrThrow(a), 0); } static zeroOfCurrency(currency: string): Amount { return new Amount(Amounts.zeroOfCurrency(currency), 0); } add(...a: AmountLike[]): Amount { if (this.saturated) { return this; } const r = Amounts.add(this.val, ...a); return new Amount(r.amount, r.saturated ? 1 : 0); } mult(n: number): Amount { if (this.saturated) { return this; } const r = Amounts.mult(this, n); return new Amount(r.amount, r.saturated ? 1 : 0); } toJson(): AmountJson { return { ...this.val }; } toString(): AmountString { return Amounts.stringify(this.val); } private constructor( private val: AmountJson, private saturated: number, ) {} } export const codecForAmountJson = (): Codec => buildCodecForObject() .property("currency", codecForString()) .property("value", codecForNumber()) .property("fraction", codecForNumber()) .build("AmountJson"); export function codecForAmountString(): Codec { return { decode(x: any, c?: Context): AmountString { if (typeof x !== "string") { throw new DecodingError( `expected string at ${renderContext(c)} but got ${typeof x}`, ); } if (Amounts.parse(x) === undefined) { throw new DecodingError( `invalid amount at ${renderContext(c)} got "${x}"`, ); } return x as AmountString; }, }; } /** * Result of a possibly overflowing operation. */ export interface Result { /** * Resulting, possibly saturated amount. */ amount: AmountJson; /** * Was there an over-/underflow? */ saturated: boolean; } /** * Type for things that are treated like amounts. */ export type AmountLike = string | AmountString | AmountJson | Amount; export interface DivmodResult { quotient: number; remainder: AmountJson; } /** * Helper class for dealing with amounts. */ export class Amounts { private constructor() { throw Error("not instantiable"); } static currencyOf(amount: AmountLike) { const amt = Amounts.parseOrThrow(amount); return amt.currency; } static zeroOfAmount(amount: AmountLike): AmountJson { const amt = Amounts.parseOrThrow(amount); return { currency: amt.currency, fraction: 0, value: 0, }; } /** * Get an amount that represents zero units of a currency. */ static zeroOfCurrency(currency: string): AmountJson { return { currency, fraction: 0, value: 0, }; } static jsonifyAmount(amt: AmountLike): AmountJson { if (typeof amt === "string") { return Amounts.parseOrThrow(amt); } if (amt instanceof Amount) { return amt.toJson(); } return amt; } static divmod(a1: AmountLike, a2: AmountLike): DivmodResult { const am1 = Amounts.jsonifyAmount(a1); const am2 = Amounts.jsonifyAmount(a2); if (am1.currency != am2.currency) { throw Error(`incompatible currency (${am1.currency} vs${am2.currency})`); } const x1 = BigInt(am1.value) * BigInt(amountFractionalBase) + BigInt(am1.fraction); const x2 = BigInt(am2.value) * BigInt(amountFractionalBase) + BigInt(am2.fraction); const quotient = x1 / x2; const remainderScaled = x1 % x2; return { quotient: Number(quotient), remainder: { currency: am1.currency, value: Number(remainderScaled / BigInt(amountFractionalBase)), fraction: Number(remainderScaled % BigInt(amountFractionalBase)), }, }; } static sum(amounts: AmountLike[]): Result { if (amounts.length <= 0) { throw Error("can't sum zero amounts"); } const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x)); return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1)); } static sumOrZero(currency: string, amounts: AmountLike[]): Result { if (amounts.length <= 0) { return { amount: Amounts.zeroOfCurrency(currency), saturated: false, }; } const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x)); return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1)); } /** * Add two amounts. Return the result and whether * the addition overflowed. The overflow is always handled * by saturating and never by wrapping. * * Throws when currencies don't match. */ static add(first: AmountLike, ...rest: AmountLike[]): Result { const firstJ = Amounts.jsonifyAmount(first); const currency = firstJ.currency; let value = firstJ.value + Math.floor(firstJ.fraction / amountFractionalBase); if (value > amountMaxValue) { return { amount: { currency, value: amountMaxValue, fraction: amountFractionalBase - 1, }, saturated: true, }; } let fraction = firstJ.fraction % amountFractionalBase; for (const x of rest) { const xJ = Amounts.jsonifyAmount(x); if (xJ.currency.toUpperCase() !== currency.toUpperCase()) { throw Error(`Mismatched currency: ${xJ.currency} and ${currency}`); } value = value + xJ.value + Math.floor((fraction + xJ.fraction) / amountFractionalBase); fraction = Math.floor((fraction + xJ.fraction) % amountFractionalBase); if (value > amountMaxValue) { return { amount: { currency, value: amountMaxValue, fraction: amountFractionalBase - 1, }, saturated: true, }; } } return { amount: { currency, value, fraction }, saturated: false }; } /** * Subtract two amounts. Return the result and whether * the subtraction overflowed. The overflow is always handled * by saturating and never by wrapping. * * Throws when currencies don't match. */ static sub(a: AmountLike, ...rest: AmountLike[]): Result { const aJ = Amounts.jsonifyAmount(a); const currency = aJ.currency; let value = aJ.value; let fraction = aJ.fraction; for (const b of rest) { const bJ = Amounts.jsonifyAmount(b); if (bJ.currency.toUpperCase() !== aJ.currency.toUpperCase()) { throw Error(`Mismatched currency: ${bJ.currency} and ${currency}`); } if (fraction < bJ.fraction) { if (value < 1) { return { amount: { currency, value: 0, fraction: 0 }, saturated: true, }; } value--; fraction += amountFractionalBase; } console.assert(fraction >= bJ.fraction); fraction -= bJ.fraction; if (value < bJ.value) { return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; } value -= bJ.value; } return { amount: { currency, value, fraction }, saturated: false }; } /** * Compare two amounts. Returns 0 when equal, -1 when a < b * and +1 when a > b. Throws when currencies don't match. */ static cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 { a = Amounts.jsonifyAmount(a); b = Amounts.jsonifyAmount(b); if (a.currency !== b.currency) { throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); } const av = a.value + Math.floor(a.fraction / amountFractionalBase); const af = a.fraction % amountFractionalBase; const bv = b.value + Math.floor(b.fraction / amountFractionalBase); const bf = b.fraction % amountFractionalBase; switch (true) { case av < bv: return -1; case av > bv: return 1; case af < bf: return -1; case af > bf: return 1; case af === bf: return 0; default: throw Error("assertion failed"); } } /** * Create a copy of an amount. */ static copy(a: AmountJson): AmountJson { return { currency: a.currency, fraction: a.fraction, value: a.value, }; } /** * Divide an amount. Throws on division by zero. */ static divide(a: AmountJson, n: number): AmountJson { if (n === 0) { throw Error(`Division by 0`); } if (n === 1) { return { value: a.value, fraction: a.fraction, currency: a.currency }; } const r = a.value % n; return { currency: a.currency, fraction: Math.floor((r * amountFractionalBase + a.fraction) / n), value: Math.floor(a.value / n), }; } /** * Check if an amount is non-zero. */ static isNonZero(a: AmountLike): boolean { a = Amounts.jsonifyAmount(a); return a.value > 0 || a.fraction > 0; } static isZero(a: AmountLike): boolean { a = Amounts.jsonifyAmount(a); return a.value === 0 && a.fraction === 0; } /** * Check whether a string is a valid currency for a Taler amount. */ static isCurrency(s: string): boolean { return /^[a-zA-Z]{1,11}$/.test(s); } /** * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. * * Currency name size limit is 11 of ASCII letters * Fraction size limit is 8 */ static parse(s: string): AmountJson | undefined { const res = s.match(/^([a-zA-Z]{1,11}):([0-9]+)([.][0-9]{1,8})?$/); if (!res) { return undefined; } const tail = res[3] || FRAC_SEPARATOR + "0"; if (tail.length > amountFractionalLength + 1) { return undefined; } const value = Number.parseInt(res[2]); if (value > amountMaxValue) { return undefined; } return { currency: res[1].toUpperCase(), fraction: Math.round(amountFractionalBase * Number.parseFloat(tail)), value, }; } /** * Parse amount in standard string form (like 'EUR:20.5'), * throw if the input is not a valid amount. */ static parseOrThrow(s: AmountLike): AmountJson { if (s instanceof Amount) { return s.toJson(); } if (typeof s === "object") { if (typeof s.currency !== "string") { throw Error("invalid amount object"); } if (typeof s.value !== "number") { throw Error("invalid amount object"); } if (typeof s.fraction !== "number") { throw Error("invalid amount object"); } return { currency: s.currency, value: s.value, fraction: s.fraction }; } else if (typeof s === "string") { const res = Amounts.parse(s); if (!res) { throw Error(`Can't parse amount: "${s}"`); } return res; } else { throw Error("invalid amount (illegal type)"); } } static min(a: AmountLike, b: AmountLike): AmountJson { const cr = Amounts.cmp(a, b); if (cr >= 0) { return Amounts.jsonifyAmount(b); } else { return Amounts.jsonifyAmount(a); } } static max(a: AmountLike, b: AmountLike): AmountJson { const cr = Amounts.cmp(a, b); if (cr >= 0) { return Amounts.jsonifyAmount(a); } else { return Amounts.jsonifyAmount(b); } } static mult(a: AmountLike, n: number): Result { a = this.jsonifyAmount(a); if (!Number.isInteger(n)) { throw Error("amount can only be multiplied by an integer"); } if (n < 0) { throw Error("amount can only be multiplied by a positive integer"); } if (n == 0) { return { amount: Amounts.zeroOfCurrency(a.currency), saturated: false, }; } let x = a; let acc = Amounts.zeroOfCurrency(a.currency); while (n > 1) { if (n % 2 == 0) { n = n / 2; } else { n = (n - 1) / 2; const r2 = Amounts.add(acc, x); if (r2.saturated) { return r2; } acc = r2.amount; } const r2 = Amounts.add(x, x); if (r2.saturated) { return r2; } x = r2.amount; } return Amounts.add(acc, x); } /** * Check if the argument is a valid amount in string form. */ static check(a: any): boolean { if (typeof a !== "string") { return false; } try { const parsedAmount = Amounts.parse(a); return !!parsedAmount; } catch { return false; } } /** * Convert to standard human-readable string representation that's * also used in JSON formats. */ static stringify(a: AmountLike): AmountString { a = Amounts.jsonifyAmount(a); const s = this.stringifyValue(a); return `${a.currency}:${s}` as AmountString; } static amountHasSameCurrency(a1: AmountLike, a2: AmountLike): boolean { const x1 = this.jsonifyAmount(a1); const x2 = this.jsonifyAmount(a2); return x1.currency.toUpperCase() === x2.currency.toUpperCase(); } static isSameCurrency(curr1: string, curr2: string): boolean { return curr1.toLowerCase() === curr2.toLowerCase(); } static stringifyValue(a: AmountLike, minFractional = 0): string { const aJ = Amounts.jsonifyAmount(a); const av = aJ.value + Math.floor(aJ.fraction / amountFractionalBase); const af = aJ.fraction % amountFractionalBase; let s = av.toString(); if (af || minFractional) { s = s + FRAC_SEPARATOR; let n = af; for (let i = 0; i < amountFractionalLength; i++) { if (!n && i >= minFractional) { break; } s = s + Math.floor((n / amountFractionalBase) * 10).toString(); n = (n * 10) % amountFractionalBase; } } return s; } /** * Number of fractional digits needed to fully represent the amount * @param a amount * @returns */ static maxFractionalDigits(a: AmountJson): number { if (a.fraction === 0) return 0; if (a.fraction < 0) { console.error("amount fraction can not be negative", a); return 0; } let i = 0; let check = true; let rest = a.fraction; while (rest > 0 && check) { check = rest % 10 === 0; rest = rest / 10; i++; } return amountFractionalLength - i + 1; } static stringifyValueWithSpec( value: AmountJson, spec: CurrencySpecification, ): { currency: string; normal: string; small?: string } { const strValue = Amounts.stringifyValue(value); const pos = strValue.indexOf(FRAC_SEPARATOR); const originalPosition = pos < 0 ? strValue.length : pos; let currency = value.currency; const names = Object.keys(spec.alt_unit_names); let FRAC_POS_NEW_POSITION = originalPosition; //find symbol //FIXME: this should be based on a cache to speed up if (names.length > 0) { let unitIndex: string = "0"; //default entry by DD51 names.forEach((index) => { const i = Number.parseInt(index, 10); if (Number.isNaN(i)) return; //skip if (originalPosition - i <= 0) return; //too big if (originalPosition - i < FRAC_POS_NEW_POSITION) { FRAC_POS_NEW_POSITION = originalPosition - i; unitIndex = index; } }); currency = spec.alt_unit_names[unitIndex]; } if (originalPosition === FRAC_POS_NEW_POSITION) { const { normal, small } = splitNormalAndSmall( strValue, originalPosition, spec, ); return { currency, normal, small }; } const intPart = strValue.substring(0, originalPosition); const fracPArt = strValue.substring(originalPosition + 1); //indexSize is always smaller than originalPosition const newValue = intPart.substring(0, FRAC_POS_NEW_POSITION) + FRAC_SEPARATOR + intPart.substring(FRAC_POS_NEW_POSITION) + fracPArt; const { normal, small } = splitNormalAndSmall( newValue, FRAC_POS_NEW_POSITION, spec, ); return { currency, normal, small }; } } function splitNormalAndSmall( decimal: string, fracSeparatorIndex: number, spec: CurrencySpecification, ): { normal: string; small?: string } { let normal: string; let small: string | undefined; if ( decimal.length - fracSeparatorIndex - 1 > spec.num_fractional_normal_digits ) { const limit = fracSeparatorIndex + spec.num_fractional_normal_digits + 1; normal = decimal.substring(0, limit); small = decimal.substring(limit); } else { normal = decimal; small = undefined; } return { normal, small }; }