diff --git a/src/amounts.ts b/src/amounts.ts index 1ab00f81d..8b5278330 100644 --- a/src/amounts.ts +++ b/src/amounts.ts @@ -38,6 +38,11 @@ export const fractionalBase = 1e8; */ 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 @@ -86,18 +91,6 @@ export interface Result { } -/** - * Get the largest amount that is safely representable. - */ -export function getMaxAmount(currency: string): AmountJson { - return { - currency, - fraction: 2 ** 32, - value: Number.MAX_SAFE_INTEGER, - }; -} - - /** * Get an amount that represents zero units of a currency. */ @@ -120,8 +113,11 @@ export function getZero(currency: string): AmountJson { export function add(first: AmountJson, ...rest: AmountJson[]): Result { const currency = first.currency; let value = first.value + Math.floor(first.fraction / fractionalBase); - if (value > Number.MAX_SAFE_INTEGER) { - return { amount: getMaxAmount(currency), saturated: true }; + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; } let fraction = first.fraction % fractionalBase; for (const x of rest) { @@ -131,8 +127,11 @@ export function add(first: AmountJson, ...rest: AmountJson[]): Result { value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); fraction = Math.floor((fraction + x.fraction) % fractionalBase); - if (value > Number.MAX_SAFE_INTEGER) { - return { amount: getMaxAmount(currency), saturated: true }; + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; } } return { amount: { currency, value, fraction }, saturated: false }; @@ -246,14 +245,22 @@ export function isNonZero(a: AmountJson): boolean { * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. */ export function parse(s: string): AmountJson|undefined { - const res = s.match(/([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?/); + 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; + } + let value = Number.parseInt(res[2]); + if (value > maxAmountValue) { + return undefined; + } return { currency: res[1], - fraction: Math.round(fractionalBase * Number.parseFloat(res[3] || "0")), - value: Number.parseInt(res[2]), + fraction: Math.round(fractionalBase * Number.parseFloat(tail)), + value, }; } @@ -288,12 +295,14 @@ export function fromFloat(floatVal: number, currency: string) { * Convert to standard human-readable string representation that's * also used in JSON formats. */ -export function toString(a: AmountJson) { - let s = a.value.toString() +export function toString(a: AmountJson): string { + const av = a.value + Math.floor(a.fraction / fractionalBase); + const af = a.fraction % fractionalBase; + let s = av.toString() - if (a.fraction) { + if (af) { s = s + "."; - let n = a.fraction; + let n = af; for (let i = 0; i < fractionalLength; i++) { if (!n) { break; @@ -310,7 +319,7 @@ export function toString(a: AmountJson) { /** * Check if the argument is a valid amount in string form. */ -export function check(a: any) { +export function check(a: any): boolean { if (typeof a !== "string") { return false; } diff --git a/src/types-test.ts b/src/types-test.ts index 626063eba..1abbfb712 100644 --- a/src/types-test.ts +++ b/src/types-test.ts @@ -30,7 +30,7 @@ test("amount addition (simple)", (t) => { test("amount addition (saturation)", (t) => { const a1 = amt(1, 0, "EUR"); - const res = Amounts.add(Amounts.getMaxAmount("EUR"), a1); + const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1); t.true(res.saturated); t.pass(); }); @@ -54,20 +54,52 @@ test("amount subtraction (saturation)", (t) => { }); +test("amount comparison", (t) => { + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0); + t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1); + t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1); + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 0, "EUR")), 1); + t.is(Amounts.cmp(amt(0, 0, "EUR"), amt(1, 0, "EUR")), -1); + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 100000000, "EUR")), 0); + t.throws(() => Amounts.cmp(amt(1, 0, "FOO"), amt(1, 0, "BAR"))); + t.pass(); +}); + + test("amount parsing", (t) => { - const a1 = Amounts.parseOrThrow("TESTKUDOS:10"); - t.is(a1.currency, "TESTKUDOS"); - t.is(a1.value, 10); - t.is(a1.fraction, 0); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), + amt(0, 0, "TESTKUDOS")), 0); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), + amt(10, 0, "TESTKUDOS")), 0); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.1"), + amt(0, 10000000, "TESTKUDOS")), 0); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.00000001"), + amt(0, 1, "TESTKUDOS")), 0); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), + amt(4503599627370496, 99999999, "TESTKUDOS")), 0); + t.throws(() => Amounts.parseOrThrow("foo:")); + t.throws(() => Amounts.parseOrThrow("1.0")); + t.throws(() => Amounts.parseOrThrow("42")); + t.throws(() => Amounts.parseOrThrow(":1.0")); + t.throws(() => Amounts.parseOrThrow(":42")); + t.throws(() => Amounts.parseOrThrow("EUR:.42")); + t.throws(() => Amounts.parseOrThrow("EUR:42.")); + t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999")); + t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.99999999"), + amt(0, 99999999, "TESTKUDOS")), 0); + t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); t.pass(); }); test("amount stringification", (t) => { + t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); t.is(Amounts.toString(amt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); t.is(Amounts.toString(amt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); + // denormalized + t.is(Amounts.toString(amt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); t.pass(); });