wallet-core/packages/taler-wallet-core/src/util/amounts.ts

424 lines
10 KiB
TypeScript
Raw Normal View History

/*
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 <http://www.gnu.org/licenses/>
*/
/**
* Types and helper functions for dealing with Taler amounts.
*/
/**
* Imports.
*/
import {
2020-08-12 12:32:58 +02:00
buildCodecForObject,
codecForString,
codecForNumber,
2020-04-06 20:02:01 +02:00
Codec,
} from "./codec";
2020-08-12 12:18:02 +02:00
import { AmountString } from "../types/talerTypes";
2018-04-09 00:41:14 +02:00
/**
* Number of fractional units that one value unit represents.
*/
export const fractionalBase = 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 fractionalLength = 8;
/**
* Maximum allowed value field of an amount.
*/
export const maxAmountValue = 2 ** 52;
/**
* 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;
}
2020-04-06 20:02:01 +02:00
export const codecForAmountJson = (): Codec<AmountJson> =>
2020-08-12 12:32:58 +02:00
buildCodecForObject<AmountJson>()
.property("currency", codecForString())
.property("value", codecForNumber())
.property("fraction", codecForNumber())
2020-04-07 10:07:32 +02:00
.build("AmountJson");
2020-08-12 12:32:58 +02:00
export const codecForAmountString = (): Codec<AmountString> => codecForString();
2020-08-12 12:18:02 +02:00
/**
* Result of a possibly overflowing operation.
*/
export interface Result {
/**
* Resulting, possibly saturated amount.
*/
amount: AmountJson;
/**
* Was there an over-/underflow?
*/
saturated: boolean;
}
/**
* Get an amount that represents zero units of a currency.
*/
export function getZero(currency: string): AmountJson {
return {
currency,
fraction: 0,
value: 0,
};
}
export type AmountLike = AmountString | AmountJson;
export function jsonifyAmount(amt: AmountLike): AmountJson {
if (typeof amt === "string") {
return parseOrThrow(amt);
}
return amt;
}
export function sum(amounts: AmountLike[]): Result {
2019-08-31 13:27:12 +02:00
if (amounts.length <= 0) {
throw Error("can't sum zero amounts");
}
const jsonAmounts = amounts.map((x) => jsonifyAmount(x));
return add(jsonAmounts[0], ...jsonAmounts.slice(1));
2019-08-31 13:27:12 +02:00
}
/**
* 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.
*/
export function add(first: AmountJson, ...rest: AmountJson[]): Result {
const currency = first.currency;
let value = first.value + Math.floor(first.fraction / fractionalBase);
if (value > maxAmountValue) {
return {
amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 },
2019-12-14 18:46:42 +01:00
saturated: true,
};
}
let fraction = first.fraction % fractionalBase;
for (const x of rest) {
if (x.currency !== currency) {
throw Error(`Mismatched currency: ${x.currency} and ${currency}`);
}
2019-12-14 18:46:42 +01:00
value =
value + x.value + Math.floor((fraction + x.fraction) / fractionalBase);
fraction = Math.floor((fraction + x.fraction) % fractionalBase);
if (value > maxAmountValue) {
return {
2019-12-14 18:46:42 +01:00
amount: {
currency,
value: maxAmountValue,
fraction: fractionalBase - 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.
*/
export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
const currency = a.currency;
let value = a.value;
let fraction = a.fraction;
for (const b of rest) {
if (b.currency !== currency) {
throw Error(`Mismatched currency: ${b.currency} and ${currency}`);
}
if (fraction < b.fraction) {
if (value < 1) {
return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
}
value--;
fraction += fractionalBase;
}
console.assert(fraction >= b.fraction);
fraction -= b.fraction;
if (value < b.value) {
return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
}
value -= b.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.
*/
export function cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 {
a = jsonifyAmount(a);
b = jsonifyAmount(b);
if (a.currency !== b.currency) {
throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
}
const av = a.value + Math.floor(a.fraction / fractionalBase);
const af = a.fraction % fractionalBase;
const bv = b.value + Math.floor(b.fraction / fractionalBase);
const bf = b.fraction % fractionalBase;
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.
*/
export function copy(a: AmountJson): AmountJson {
return {
currency: a.currency,
fraction: a.fraction,
value: a.value,
};
}
/**
* Divide an amount. Throws on division by zero.
*/
export function divide(a: AmountJson, n: number): AmountJson {
if (n === 0) {
throw Error(`Division by 0`);
}
if (n === 1) {
2019-12-14 18:46:42 +01:00
return { value: a.value, fraction: a.fraction, currency: a.currency };
}
const r = a.value % n;
return {
currency: a.currency,
2019-12-14 18:46:42 +01:00
fraction: Math.floor((r * fractionalBase + a.fraction) / n),
value: Math.floor(a.value / n),
};
}
/**
* Check if an amount is non-zero.
*/
export function isNonZero(a: AmountJson): boolean {
return a.value > 0 || a.fraction > 0;
}
export function isZero(a: AmountLike): boolean {
a = jsonifyAmount(a);
2019-12-25 19:11:20 +01:00
return a.value === 0 && a.fraction === 0;
}
/**
* Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
*/
2019-12-14 18:46:42 +01:00
export function parse(s: string): AmountJson | undefined {
const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/);
if (!res) {
return undefined;
}
const tail = res[3] || ".0";
if (tail.length > fractionalLength + 1) {
return undefined;
}
2020-04-06 17:45:41 +02:00
const value = Number.parseInt(res[2]);
if (value > maxAmountValue) {
return undefined;
}
return {
currency: res[1],
fraction: Math.round(fractionalBase * Number.parseFloat(tail)),
value,
};
}
2018-04-09 00:41:14 +02:00
/**
* Parse amount in standard string form (like 'EUR:20.5'),
* throw if the input is not a valid amount.
*/
export function parseOrThrow(s: string): AmountJson {
const res = parse(s);
if (!res) {
throw Error(`Can't parse amount: "${s}"`);
}
return res;
}
/**
* Convert a float to a Taler amount.
* Loss of precision possible.
*/
2020-04-06 20:02:01 +02:00
export function fromFloat(floatVal: number, currency: string): AmountJson {
return {
currency,
fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase),
value: Math.floor(floatVal),
};
}
/**
* Convert to standard human-readable string representation that's
* also used in JSON formats.
*/
export function stringify(a: AmountLike): string {
a = jsonifyAmount(a);
const av = a.value + Math.floor(a.fraction / fractionalBase);
const af = a.fraction % fractionalBase;
2019-12-14 18:46:42 +01:00
let s = av.toString();
if (af) {
s = s + ".";
let n = af;
for (let i = 0; i < fractionalLength; i++) {
if (!n) {
break;
}
2019-12-14 18:46:42 +01:00
s = s + Math.floor((n / fractionalBase) * 10).toString();
n = (n * 10) % fractionalBase;
}
}
return `${a.currency}:${s}`;
}
2018-04-09 00:41:14 +02:00
/**
* Check if the argument is a valid amount in string form.
*/
function check(a: any): boolean {
if (typeof a !== "string") {
return false;
}
try {
const parsedAmount = parse(a);
return !!parsedAmount;
} catch {
return false;
}
}
function mult(a: AmountJson, n: number): Result {
if (!Number.isInteger(n)) {
throw Error("amount can only be multipied by an integer");
}
if (n < 0) {
throw Error("amount can only be multiplied by a positive integer");
}
if (n == 0) {
return { amount: getZero(a.currency), saturated: false };
}
2020-07-16 13:51:12 +02:00
let x = a;
let acc = getZero(a.currency);
while (n > 1) {
if (n % 2 == 0) {
n = n / 2;
} else {
2020-07-16 13:51:12 +02:00
n = (n - 1) / 2;
const r2 = add(acc, x);
2020-07-16 13:51:12 +02:00
if (r2.saturated) {
return r2;
}
acc = r2.amount;
}
2020-07-16 13:51:12 +02:00
const r2 = add(x, x);
if (r2.saturated) {
return r2;
}
2020-07-16 13:51:12 +02:00
x = r2.amount;
}
2020-07-16 13:51:12 +02:00
return add(acc, x);
}
function max(a: AmountLike, b: AmountLike): AmountJson {
const cr = Amounts.cmp(a, b);
if (cr >= 0) {
return jsonifyAmount(a);
} else {
return jsonifyAmount(b);
}
}
function min(a: AmountLike, b: AmountLike): AmountJson {
const cr = Amounts.cmp(a, b);
if (cr >= 0) {
return jsonifyAmount(b);
} else {
return jsonifyAmount(a);
}
}
// Export all amount-related functions here for better IDE experience.
export const Amounts = {
stringify: stringify,
parse: parse,
parseOrThrow: parseOrThrow,
cmp: cmp,
add: add,
sum: sum,
sub: sub,
mult: mult,
max: max,
min: min,
check: check,
getZero: getZero,
isZero: isZero,
maxAmountValue: maxAmountValue,
fromFloat: fromFloat,
copy: copy,
fractionalBase: fractionalBase,
2021-01-04 13:30:38 +01:00
divide: divide,
2020-04-02 17:14:12 +02:00
};