summaryrefslogtreecommitdiff
path: root/packages/taler-util/src/amounts.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-util/src/amounts.ts')
-rw-r--r--packages/taler-util/src/amounts.ts357
1 files changed, 297 insertions, 60 deletions
diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts
index 98cd4ad62..82a3d3b68 100644
--- a/packages/taler-util/src/amounts.ts
+++ b/packages/taler-util/src/amounts.ts
@@ -22,12 +22,16 @@
* Imports.
*/
import {
+ Codec,
+ Context,
+ DecodingError,
buildCodecForObject,
- codecForString,
codecForNumber,
- Codec,
+ codecForString,
+ renderContext,
} from "./codec.js";
-import { AmountString } from "./talerTypes.js";
+import { CurrencySpecification } from "./index.js";
+import { AmountString } from "./taler-types.js";
/**
* Number of fractional units that one value unit represents.
@@ -47,6 +51,11 @@ export const amountFractionalLength = 8;
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.
*/
@@ -67,6 +76,48 @@ export interface AmountJson {
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<AmountJson> =>
buildCodecForObject<AmountJson>()
.property("currency", codecForString())
@@ -74,7 +125,23 @@ export const codecForAmountJson = (): Codec<AmountJson> =>
.property("fraction", codecForNumber())
.build("AmountJson");
-export const codecForAmountString = (): Codec<AmountString> => codecForString();
+export function codecForAmountString(): Codec<AmountString> {
+ 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.
@@ -93,7 +160,12 @@ export interface Result {
/**
* Type for things that are treated like amounts.
*/
-export type AmountLike = AmountString | AmountJson;
+export type AmountLike = string | AmountString | AmountJson | Amount;
+
+export interface DivmodResult {
+ quotient: number;
+ remainder: AmountJson;
+}
/**
* Helper class for dealing with amounts.
@@ -103,10 +175,24 @@ export class Amounts {
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 getZero(currency: string): AmountJson {
+ static zeroOfCurrency(currency: string): AmountJson {
return {
currency,
fraction: 0,
@@ -118,9 +204,37 @@ export class Amounts {
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");
@@ -132,7 +246,7 @@ export class Amounts {
static sumOrZero(currency: string, amounts: AmountLike[]): Result {
if (amounts.length <= 0) {
return {
- amount: Amounts.getZero(currency),
+ amount: Amounts.zeroOfCurrency(currency),
saturated: false,
};
}
@@ -147,9 +261,11 @@ export class Amounts {
*
* Throws when currencies don't match.
*/
- static add(first: AmountJson, ...rest: AmountJson[]): Result {
- const currency = first.currency;
- let value = first.value + Math.floor(first.fraction / amountFractionalBase);
+ 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: {
@@ -160,17 +276,18 @@ export class Amounts {
saturated: true,
};
}
- let fraction = first.fraction % amountFractionalBase;
+ let fraction = firstJ.fraction % amountFractionalBase;
for (const x of rest) {
- if (x.currency.toUpperCase() !== currency.toUpperCase()) {
- throw Error(`Mismatched currency: ${x.currency} and ${currency}`);
+ const xJ = Amounts.jsonifyAmount(x);
+ if (xJ.currency.toUpperCase() !== currency.toUpperCase()) {
+ throw Error(`Mismatched currency: ${xJ.currency} and ${currency}`);
}
value =
value +
- x.value +
- Math.floor((fraction + x.fraction) / amountFractionalBase);
- fraction = Math.floor((fraction + x.fraction) % amountFractionalBase);
+ xJ.value +
+ Math.floor((fraction + xJ.fraction) / amountFractionalBase);
+ fraction = Math.floor((fraction + xJ.fraction) % amountFractionalBase);
if (value > amountMaxValue) {
return {
amount: {
@@ -192,16 +309,18 @@ export class Amounts {
*
* Throws when currencies don't match.
*/
- static sub(a: AmountJson, ...rest: AmountJson[]): Result {
- const currency = a.currency;
- let value = a.value;
- let fraction = a.fraction;
+ 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) {
- if (b.currency.toUpperCase() !== a.currency.toUpperCase()) {
- throw Error(`Mismatched currency: ${b.currency} and ${currency}`);
+ const bJ = Amounts.jsonifyAmount(b);
+ if (bJ.currency.toUpperCase() !== aJ.currency.toUpperCase()) {
+ throw Error(`Mismatched currency: ${bJ.currency} and ${currency}`);
}
- if (fraction < b.fraction) {
+ if (fraction < bJ.fraction) {
if (value < 1) {
return {
amount: { currency, value: 0, fraction: 0 },
@@ -211,12 +330,12 @@ export class Amounts {
value--;
fraction += amountFractionalBase;
}
- console.assert(fraction >= b.fraction);
- fraction -= b.fraction;
- if (value < b.value) {
+ console.assert(fraction >= bJ.fraction);
+ fraction -= bJ.fraction;
+ if (value < bJ.value) {
return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
}
- value -= b.value;
+ value -= bJ.value;
}
return { amount: { currency, value, fraction }, saturated: false };
@@ -284,7 +403,8 @@ export class Amounts {
/**
* Check if an amount is non-zero.
*/
- static isNonZero(a: AmountJson): boolean {
+ static isNonZero(a: AmountLike): boolean {
+ a = Amounts.jsonifyAmount(a);
return a.value > 0 || a.fraction > 0;
}
@@ -294,14 +414,24 @@ export class Amounts {
}
/**
+ * 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-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/);
+ const res = s.match(/^([a-zA-Z]{1,11}):([0-9]+)([.][0-9]{1,8})?$/);
if (!res) {
return undefined;
}
- const tail = res[3] || ".0";
+ const tail = res[3] || FRAC_SEPARATOR + "0";
if (tail.length > amountFractionalLength + 1) {
return undefined;
}
@@ -320,26 +450,30 @@ export class Amounts {
* Parse amount in standard string form (like 'EUR:20.5'),
* throw if the input is not a valid amount.
*/
- static parseOrThrow(s: string): AmountJson {
- const res = Amounts.parse(s);
- if (!res) {
- throw Error(`Can't parse amount: "${s}"`);
+ 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)");
}
- return res;
- }
-
- /**
- * Convert a float to a Taler amount.
- * Loss of precision possible.
- */
- static fromFloat(floatVal: number, currency: string): AmountJson {
- return {
- currency,
- fraction: Math.floor(
- (floatVal - Math.floor(floatVal)) * amountFractionalBase,
- ),
- value: Math.floor(floatVal),
- };
}
static min(a: AmountLike, b: AmountLike): AmountJson {
@@ -363,16 +497,19 @@ export class Amounts {
static mult(a: AmountLike, n: number): Result {
a = this.jsonifyAmount(a);
if (!Number.isInteger(n)) {
- throw Error("amount can only be multipied by an integer");
+ 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.getZero(a.currency), saturated: false };
+ return {
+ amount: Amounts.zeroOfCurrency(a.currency),
+ saturated: false,
+ };
}
let x = a;
- let acc = Amounts.getZero(a.currency);
+ let acc = Amounts.zeroOfCurrency(a.currency);
while (n > 1) {
if (n % 2 == 0) {
n = n / 2;
@@ -412,26 +549,31 @@ export class Amounts {
* Convert to standard human-readable string representation that's
* also used in JSON formats.
*/
- static stringify(a: AmountLike): string {
+ static stringify(a: AmountLike): AmountString {
a = Amounts.jsonifyAmount(a);
const s = this.stringifyValue(a);
- return `${a.currency}:${s}`;
+ return `${a.currency}:${s}` as AmountString;
}
- static isSameCurrency(a1: AmountLike, a2: AmountLike): boolean {
+ 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 stringifyValue(a: AmountJson, minFractional = 0): string {
- const av = a.value + Math.floor(a.fraction / amountFractionalBase);
- const af = a.fraction % amountFractionalBase;
+ 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) {
- s = s + ".";
+ if (af || minFractional) {
+ s = s + FRAC_SEPARATOR;
let n = af;
for (let i = 0; i < amountFractionalLength; i++) {
if (!n && i >= minFractional) {
@@ -444,4 +586,99 @@ export class Amounts {
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 };
}