commit bd26a6d0af141be293f6257b440d1d06c48dac89 parent 74f553cb5d405ad73d73ce84d9bd29e4291454b7 Author: Florian Dold <dold@taler.net> Date: Tue, 11 Aug 2026 19:54:06 +0200 util: update shared helpers and existing consumers Various fixes, as well as a duration parser that accepts the same format as the one in GNUnet. Diffstat:
13 files changed, 221 insertions(+), 98 deletions(-)
diff --git a/packages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx b/packages/taler-merchant-webui/src/paths/instance/orders/create/CreatePage.tsx @@ -74,6 +74,7 @@ import { WithId } from "../../../../declaration.js"; import { UIElement } from "../../../../hooks/preference.js"; import { useInstanceTokenFamilies } from "../../../../hooks/tokenfamily.js"; import { rate } from "../../../../utils/amount.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { LimitedKycActionWarning, @@ -374,12 +375,14 @@ export function CreatePage({ return i18n.str`Unauthorized`; case TalerErrorCode.MERCHANT_PRIVATE_POST_ORDERS_INSTANCE_CONFIGURATION_LACKS_WIRE: return i18n.str`No active bank accounts configured. At least one bank account must be available to create new orders.`; + case TalerErrorCode.MERCHANT_GENERIC_CURRENCY_MISMATCH: + return i18n.str`The amount is not in a currency that this instance accepts.`; case HttpStatusCode.Gone: return i18n.str`Product with ID "${fail.body.product_id}" is out of stock. You can sell at most ${fail.body.available_quantity} in the current stock.`; case HttpStatusCode.UnavailableForLegalReasons: return i18n.str`No payment service would accept a payment because of KYC requirements.`; default: - return i18n.str`The order could not be created.`; + return unexpectedTalerFailure(i18n, fail); } }); const addProductToTheInventoryList = ( diff --git a/packages/taler-merchant-webui/src/paths/instance/products/list/Table.tsx b/packages/taler-merchant-webui/src/paths/instance/products/list/Table.tsx @@ -54,6 +54,7 @@ import { usePreference, } from "../../../../hooks/preference.js"; import { Tooltip } from "../../../../components/Tooltip.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; import { PaginationRow } from "../../orders/list/Table.js"; const TALER_SCREEN_ID = 56; @@ -163,7 +164,7 @@ function Table({ case HttpStatusCode.Conflict: return i18n.str`This change was based on outdated information. Please reload and try again.`; default: - return i18n.str`The product could not be updated.`; + return unexpectedTalerFailure(i18n, fail); } }); return ( diff --git a/packages/taler-merchant-webui/src/paths/instance/products/update/UpdatePage.tsx b/packages/taler-merchant-webui/src/paths/instance/products/update/UpdatePage.tsx @@ -39,6 +39,7 @@ import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchan import { useInstanceProductGroups } from "../../../../hooks/groups.js"; import { useInstanceMoneyPots } from "../../../../hooks/pots.js"; import { Tooltip } from "../../../../components/Tooltip.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; const TALER_SCREEN_ID = 57; @@ -74,7 +75,7 @@ export function UpdatePage({ product, onBack, onConfirm }: Props): VNode { case HttpStatusCode.Conflict: return i18n.str`Conflict.`; default: - return i18n.str`The product could not be updated.`; + return unexpectedTalerFailure(i18n, fail); } }); diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/create/CreatePage.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/create/CreatePage.tsx @@ -63,6 +63,7 @@ import { useCurrenciesContext } from "../../../../context/currency.js"; import { useSessionContext } from "../../../../context/session.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; import { UIElement } from "../../../../hooks/preference.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; import { LimitedKycActionWarning, MissingBankAccountsWarning, @@ -297,7 +298,7 @@ export function CreatePage({ case HttpStatusCode.Conflict: return i18n.str`There is a template with this identifier, please choose another one.`; default: - return i18n.str`The template could not be created.`; + return unexpectedTalerFailure(i18n, fail); } }); diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/update/UpdatePage.tsx @@ -69,6 +69,7 @@ import { RenderChoices, } from "../../orders/create/CreatePage.js"; import { InputCustom } from "../../../../components/form/InputCustom.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; import { isValidRegex } from "../create/CreatePage.js"; const TALER_SCREEN_ID = 65; @@ -334,7 +335,7 @@ function UpdateFixedOrderPage({ template, onUpdated, onBack }: Props): VNode { case HttpStatusCode.Conflict: return i18n.str`Conflict`; default: - return i18n.str`The template could not be updated.`; + return unexpectedTalerFailure(i18n, fail); } }); diff --git a/packages/taler-merchant-webui/src/paths/instance/templates/use/UsePage.tsx b/packages/taler-merchant-webui/src/paths/instance/templates/use/UsePage.tsx @@ -44,6 +44,7 @@ import { Input } from "../../../../components/form/Input.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { Tooltip } from "../../../../components/Tooltip.js"; import { useSessionContext } from "../../../../context/session.js"; +import { unexpectedTalerFailure } from "../../../../utils/errors.js"; const TALER_SCREEN_ID = 66; @@ -125,14 +126,17 @@ function UseFixedOrderPage({ case TalerErrorCode.MERCHANT_PRIVATE_POST_ORDERS_INSTANCE_CONFIGURATION_LACKS_WIRE: return i18n.str`No active bank accounts configured. At least one bank account must be available to create new orders`; case TalerErrorCode.MERCHANT_POST_USING_TEMPLATES_AMOUNT_CONFLICT_TEMPLATES_CONTRACT_AMOUNT: + return i18n.str`The template fixes the amount, it can't be chosen here.`; case TalerErrorCode.MERCHANT_POST_USING_TEMPLATES_SUMMARY_CONFLICT_TEMPLATES_CONTRACT_SUBJECT: return i18n.str`Conflict.`; + case TalerErrorCode.MERCHANT_GENERIC_CURRENCY_MISMATCH: + return i18n.str`The amount is not in the currency of the template.`; case HttpStatusCode.Gone: return i18n.str`No more stock for product with ID "${fail.body.product_id}".`; case HttpStatusCode.UnavailableForLegalReasons: return i18n.str`No payment service would accept a payment because of KYC requirements.`; default: - return i18n.str`The order could not be created from this template.`; + return unexpectedTalerFailure(i18n, fail); } }); diff --git a/packages/taler-merchant-webui/src/utils/errors.ts b/packages/taler-merchant-webui/src/utils/errors.ts @@ -0,0 +1,41 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + InternationalizationAPI, + OperationFail, + TalerErrorCode, + TranslatedString, +} from "@gnu-taler/taler-util"; + +/** + * Message for a failure that the API client reports by Taler error code. + * + * Endpoints that answer 400 or 409 report the error code the backend sent, + * which is an open set: a screen names the codes it has something better to + * say about, and leaves the rest to this fallback. The backend's hint is + * more specific than anything we could write here, so prefer it. + */ +export function unexpectedTalerFailure( + i18n: InternationalizationAPI, + fail: OperationFail<TalerErrorCode>, +): TranslatedString { + const hint = fail.detail?.hint; + if (hint) { + return i18n.str`The server rejected the request: ${hint}`; + } + return i18n.str`Unexpected error (code ${fail.case}).`; +} diff --git a/packages/taler-util/src/time.test.ts b/packages/taler-util/src/time.test.ts @@ -33,28 +33,40 @@ test("duration parsing", (t) => { const d2 = Duration.fromPrettyString(" 2h 1s 3m"); assert.deepStrictEqual(d2.d_ms, 2 * 60 * 60 * 1000 + 3 * 60 * 1000 + 1000); - assert.throws(() => { - Duration.fromPrettyString("5g"); + const d3 = Duration.fromPrettyString("2 weeks 3ms 4us"); + assert.deepStrictEqual(d3.d_ms, 2 * 7 * 24 * 60 * 60 * 1000 + 3.004); + assert.deepStrictEqual(Duration.toTalerProtocolDuration(Duration.fromPrettyString("1001us")), { + d_us: 1001, }); + assert.deepStrictEqual(Duration.fromTalerProtocolDuration({ d_us: 1001 }), { d_ms: 1.001 }); + + const d4 = Duration.fromPrettyString("1 YEAR 2min 3\""); + assert.deepStrictEqual(d4.d_ms, 365 * 24 * 60 * 60 * 1000 + 2 * 60 * 1000 + 3000); + + assert.deepStrictEqual(Duration.fromPrettyString("forever"), { d_ms: "forever" }); + assert.throws(() => { - Duration.fromPrettyString("s"); + Duration.fromPrettyString("5g"); }); assert.throws(() => { Duration.fromPrettyString("s5"); }); - assert.throws(() => { - Duration.fromPrettyString("5 5 s"); - }); }); -test("duration parsing rejects a trailing number with no unit", (t) => { - // A number that is never flushed into the accumulator was silently - // discarded, so these all yielded a *smaller* duration with no error. - assert.throws(() => Duration.fromPrettyString("5")); - assert.throws(() => Duration.fromPrettyString("1h 30")); - assert.throws(() => Duration.fromPrettyString("1h30")); - assert.throws(() => Duration.fromPrettyString("")); - assert.throws(() => Duration.fromPrettyString(" ")); +test("duration parsing follows GNUnet's bare-number semantics", () => { + assert.deepStrictEqual(Duration.fromPrettyString("5"), { d_ms: 0.005 }); + assert.deepStrictEqual(Duration.fromPrettyString("1h 30"), { d_ms: 3600000.03 }); + assert.deepStrictEqual(Duration.fromPrettyString(""), { d_ms: 0 }); + assert.deepStrictEqual(Duration.fromPrettyString("s"), { d_ms: 0 }); +}); + +test("duration pretty formatting normalizes the parser's complete grammar", () => { + const parsed = Duration.fromPrettyString("2 hours 1s 3m"); + assert.strictEqual(Duration.formatPretty(parsed), "2h 3m 1s"); + assert.strictEqual(Duration.formatPretty({ d_us: 7 * 86400 * 1_000_000 }), "1w"); + assert.strictEqual(Duration.formatPretty({ d_us: 1_001_001 }), "1s 1ms 1us"); + assert.strictEqual(Duration.formatPretty({ d_us: 0 }), "0us"); + assert.strictEqual(Duration.formatPretty({ d_us: "forever" }), "forever"); }); test("time codecs reject non-integral and negative values", (t) => { @@ -145,5 +157,3 @@ test("AbsoluteTime.formatTimestamp handles fallback and never values", (t) => { assert.strictEqual(AbsoluteTime.formatTimestamp(null, "N/A"), "N/A"); assert.strictEqual(AbsoluteTime.formatTimestamp(null, "—"), "—"); }); - - diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts @@ -252,67 +252,72 @@ export namespace Duration { } export function fromPrettyString(s: string): Duration { - let dMs = 0; - let currentNum = ""; - let parsingNum = true; - let sawUnit = false; - for (let i = 0; i < s.length; i++) { - const cc = s.charCodeAt(i); - if (cc >= "0".charCodeAt(0) && cc <= "9".charCodeAt(0)) { - if (!parsingNum) { - throw Error("invalid duration, unexpected number"); - } - currentNum += s[i]; - continue; - } - if (s[i] == " ") { - if (currentNum != "") { - parsingNum = false; - } - continue; - } - - if (currentNum == "") { - throw Error("invalid duration, missing number"); - } + // Keep this grammar in sync with GNUNET_STRINGS_fancy_time_to_relative(). + // A bare number is microseconds, and numbers and their units may either be + // adjacent ("15m") or separated by spaces ("15 m"). + if (s.trim().toLowerCase() === "forever") { + return { d_ms: "forever" }; + } + const unitUs: Record<string, number> = { + us: 1, + ms: 1_000, + s: 1_000_000, + second: 1_000_000, + seconds: 1_000_000, + '"': 1_000_000, + m: 60 * 1_000_000, + min: 60 * 1_000_000, + minute: 60 * 1_000_000, + minutes: 60 * 1_000_000, + "'": 60 * 1_000_000, + h: 60 * 60 * 1_000_000, + hour: 60 * 60 * 1_000_000, + hours: 60 * 60 * 1_000_000, + d: 24 * 60 * 60 * 1_000_000, + day: 24 * 60 * 60 * 1_000_000, + days: 24 * 60 * 60 * 1_000_000, + w: 7 * 24 * 60 * 60 * 1_000_000, + week: 7 * 24 * 60 * 60 * 1_000_000, + weeks: 7 * 24 * 60 * 60 * 1_000_000, + y: 365 * 24 * 60 * 60 * 1_000_000, + a: 365 * 24 * 60 * 60 * 1_000_000, + year: 365 * 24 * 60 * 60 * 1_000_000, + years: 365 * 24 * 60 * 60 * 1_000_000, + }; - if (s[i] === "s") { - if (s.startsWith("seconds", i)) { - i += "seconds".length - 1; - } - dMs += 1000 * Number.parseInt(currentNum, 10); - } else if (s[i] === "m") { - if (s.startsWith("minutes", i)) { - i += "minutes".length - 1; + let totalUs = 0; + let pending = 0; + for (const token of s.split(" ").filter(Boolean)) { + const unit = unitUs[token.toLowerCase()]; + if (unit !== undefined) { + pending *= unit; + } else { + const match = /^(\d+)(.*)$/.exec(token); + if (!match) { + throw Error("invalid duration, expected a number or unit"); } - dMs += 60 * 1000 * Number.parseInt(currentNum, 10); - } else if (s[i] === "h") { - if (s.startsWith("hours", i)) { - i += "hours".length - 1; + totalUs += pending; + pending = Number(match[1]); + if (!Number.isSafeInteger(pending)) { + throw Error("invalid duration, number is too large"); } - dMs += 60 * 60 * 1000 * Number.parseInt(currentNum, 10); - } else if (s[i] === "d") { - if (s.startsWith("days", i)) { - i += "days".length - 1; + if (match[2]) { + const attachedUnit = unitUs[match[2].toLowerCase()]; + if (attachedUnit === undefined) { + throw Error("invalid duration, unsupported unit"); + } + pending *= attachedUnit; } - dMs += 24 * 60 * 60 * 1000 * Number.parseInt(currentNum, 10); - } else { - throw Error("invalid duration, unsupported unit"); } - sawUnit = true; - currentNum = ""; - parsingNum = true; - } - // A number is only added when its unit is seen; anything left has none. - if (currentNum !== "") { - throw Error("invalid duration, number without unit"); + if (!Number.isSafeInteger(totalUs) || !Number.isSafeInteger(pending)) { + throw Error("invalid duration, value is too large"); + } } - if (dMs === 0 && !sawUnit) { - throw Error("invalid duration, no unit given"); + totalUs += pending; + if (!Number.isSafeInteger(totalUs)) { + throw Error("invalid duration, value is too large"); } - return { - d_ms: dMs, - }; + return { d_ms: totalUs / 1_000 }; } /** @@ -450,7 +455,7 @@ export namespace Duration { }; } return { - d_ms: Math.floor(d.d_us / 1000), + d_ms: d.d_us / 1000, }; } @@ -461,7 +466,10 @@ export namespace Duration { }; } return { - d_us: d.d_ms * 1000, + // A Duration stores milliseconds as a JavaScript number. A value such + // as 1.001 ms is an exact 1001-us protocol duration, but multiplication + // can produce 1000.9999999999999 in binary floating point. + d_us: Math.round(d.d_ms * 1000), }; } @@ -491,7 +499,7 @@ export namespace Duration { if ("d_us" in duration) { us = duration.d_us; } else if ("d_ms" in duration) { - us = duration.d_ms === "forever" ? "forever" : duration.d_ms * 1000; + us = duration.d_ms === "forever" ? "forever" : Math.round(duration.d_ms * 1000); } else { return undefined; } @@ -505,6 +513,65 @@ export namespace Duration { if (us % MINUTE_US === 0) return `${us / MINUTE_US}m`; return `${Math.round(us / MINUTE_US)}m`; } + + /** + * Render a finite duration in the complete grammar accepted by + * {@link fromPrettyString}, with the largest units first. + * + * Unlike {@link formatShort}, this retains the seconds part of mixed values, + * making it suitable for showing a normalized form of a duration a user + * entered in the free-form duration input. + */ + export function formatPretty( + duration: DurationLike | undefined | null, + ): string | undefined { + if (!duration) return undefined; + let us: number | "forever"; + if ("d_us" in duration) { + us = duration.d_us; + } else if ("d_ms" in duration) { + us = duration.d_ms === "forever" ? "forever" : Math.round(duration.d_ms * 1000); + } else { + return undefined; + } + if (us === "forever") return "forever"; + if (typeof us !== "number" || !isFinite(us) || us < 0) { + return undefined; + } + if (!Number.isSafeInteger(us)) return undefined; + + const YEAR_US = 365 * 24 * 60 * 60 * 1_000_000; + const WEEK_US = 7 * 24 * 60 * 60 * 1_000_000; + const DAY_US = 24 * 60 * 60 * 1_000_000; + const HOUR_US = 60 * 60 * 1_000_000; + const MINUTE_US = 60 * 1_000_000; + const SECOND_US = 1_000_000; + const years = Math.floor(us / YEAR_US); + us -= years * YEAR_US; + const weeks = Math.floor(us / WEEK_US); + us -= weeks * WEEK_US; + const days = Math.floor(us / DAY_US); + us -= days * DAY_US; + const hours = Math.floor(us / HOUR_US); + us -= hours * HOUR_US; + const minutes = Math.floor(us / MINUTE_US); + us -= minutes * MINUTE_US; + const seconds = Math.floor(us / SECOND_US); + us -= seconds * SECOND_US; + const milliseconds = Math.floor(us / 1_000); + us -= milliseconds * 1_000; + + const pieces: string[] = []; + if (years) pieces.push(`${years}y`); + if (weeks) pieces.push(`${weeks}w`); + if (days) pieces.push(`${days}d`); + if (hours) pieces.push(`${hours}h`); + if (minutes) pieces.push(`${minutes}m`); + if (seconds) pieces.push(`${seconds}s`); + if (milliseconds) pieces.push(`${milliseconds}ms`); + if (us || pieces.length === 0) pieces.push(`${us}us`); + return pieces.join(" "); + } } export namespace AbsoluteTime { diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/state.ts b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts @@ -31,12 +31,10 @@ export function useComponentState({ }: Props): State { const api = useBackendContext(); const { pushAlertOnError } = useAlertContext(); - const zero = Amounts.zeroOfCurrency(scope.currency); - const [amount, setAmount] = useState(zero); + const [amount, setAmount] = useState(Amounts.zeroOfCurrency(scope.currency)); const amountStr = Amounts.stringify(amount); const hook = useAsyncAsHook(async () => { - if (Amounts.isZero(amount)) return undefined const deposit = await api.wallet.call(WalletApiOperation.CheckDeposit, { amount: amountStr, depositPaytoUri: Paytos.toFullString(account), @@ -58,14 +56,14 @@ export function useComponentState({ // } const debitAmount = - !hook || hook.hasError || !hook.response - ? zero + !hook || hook.hasError + ? Amounts.zeroOfCurrency(scope.currency) : Amounts.parseOrThrow(hook.response.effectiveDepositAmount); const toBeReceived = - !hook || hook.hasError || !hook.response - ? zero + !hook || hook.hasError + ? Amounts.zeroOfCurrency(scope.currency) : Amounts.parseOrThrow(hook.response.totalDepositCost); - + // const { deposit, uri, amount } = hook.response; async function doDeposit(): Promise<void> { const resp = await api.wallet.call(WalletApiOperation.CreateDepositGroup, { amount: amountStr, diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts @@ -52,15 +52,14 @@ export function useAsyncAsHook<T>( ): HookResponseWithRetry<T> { const [result, setHookResponse] = useState<HookResponse<T>>(undefined); const unload = useRef(false); - async function doAsync(): Promise<void> { try { const response = await fn(); if (response === false || unload.current) return; setHookResponse({ hasError: false, response }); } catch (e) { - console.log(e); - if (unload.current) return; + console.log(e) + if (unload.current) return if (e instanceof TalerError) { setHookResponse({ hasError: true, @@ -87,13 +86,10 @@ export function useAsyncAsHook<T>( useEffect(() => { doAsync(); - }, deps ?? []); - - useEffect(() => { return () => { - unload.current = true; - }; - }, []); + unload.current = true + } + }, deps ?? []); if (!result) return undefined; return { ...result, retry: doAsync }; diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts @@ -142,9 +142,9 @@ export const defaultSettings: Settings = { extendedAccountTypes: false, showExchangeManagement: false, walletAllowHttp: false, + walletMigrateNativeDb: false, selectTosFormat: false, showWalletActivity: false, - walletMigrateNativeDb: false, }; /** diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts @@ -45,7 +45,7 @@ function getBrowserLang( if (window.navigator.languages) { const match = Object.entries(completeness) .filter(([code, value]) => { - if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below 90% + if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below the threshold return ( window.navigator.languages.findIndex((l) => l.startsWith(code)) !== -1 );