/*
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 };
}