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.ts229
1 files changed, 203 insertions, 26 deletions
diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts
index 991b13912..82a3d3b68 100644
--- a/packages/taler-util/src/amounts.ts
+++ b/packages/taler-util/src/amounts.ts
@@ -22,11 +22,15 @@
* Imports.
*/
import {
+ Codec,
+ Context,
+ DecodingError,
buildCodecForObject,
- codecForString,
codecForNumber,
- Codec,
+ codecForString,
+ renderContext,
} from "./codec.js";
+import { CurrencySpecification } from "./index.js";
import { AmountString } from "./taler-types.js";
/**
@@ -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.
@@ -132,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");
@@ -303,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;
}
@@ -313,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;
}
@@ -340,6 +451,9 @@ export class Amounts {
* 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");
@@ -362,20 +476,6 @@ export class Amounts {
}
}
- /**
- * 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 {
const cr = Amounts.cmp(a, b);
if (cr >= 0) {
@@ -397,7 +497,7 @@ 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");
@@ -449,19 +549,23 @@ 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 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);
@@ -469,7 +573,7 @@ export class Amounts {
let s = av.toString();
if (af || minFractional) {
- s = s + ".";
+ s = s + FRAC_SEPARATOR;
let n = af;
for (let i = 0; i < amountFractionalLength; i++) {
if (!n && i >= minFractional) {
@@ -504,4 +608,77 @@ export class Amounts {
}
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 };
}