diff options
Diffstat (limited to 'packages/taler-util/src/amounts.ts')
-rw-r--r-- | packages/taler-util/src/amounts.ts | 229 |
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 }; } |