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