commit 1c52cf5360f7fa2d62e85a41458864a51256b48c
parent 5f0d7e308a9ed0535ccebaea269d2a3615654acf
Author: Florian Dold <dold@taler.net>
Date: Sat, 8 Aug 2026 10:32:33 +0200
util: add amount, tan, date, payto and fake-http helpers for webui
Diffstat:
11 files changed, 813 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/amounts.test.ts b/packages/taler-util/src/amounts.test.ts
@@ -483,3 +483,38 @@ test("division normalizes and validates its dividend", (t) => {
});
assert.throws(() => divide({ value: -5, fraction: 0, currency: "EUR" }, 2));
});
+
+test("Amounts.formatAmount separates currency from value and formats correctly", (t) => {
+ assert.strictEqual(Amounts.formatAmount("CHF:12.50"), "CHF 12.50");
+ assert.strictEqual(Amounts.formatAmount("EUR:0.01"), "EUR 0.01");
+ // Trailing zeros are kept: prices read wrong without them.
+ assert.strictEqual(Amounts.formatAmount("CHF:3"), "CHF 3.00");
+ assert.strictEqual(Amounts.formatAmount("CHF:30000"), "CHF 30000.00");
+ // Also supports AmountJson object input
+ assert.strictEqual(
+ Amounts.formatAmount({ currency: "CHF", value: 12, fraction: 50000000 }),
+ "CHF 12.50",
+ );
+});
+
+test("Amounts.formatAmount says nothing rather than zero when there is no amount", (t) => {
+ assert.strictEqual(Amounts.formatAmount(undefined), "—");
+ assert.strictEqual(Amounts.formatAmount(null), "—");
+ assert.strictEqual(Amounts.formatAmount(""), "—");
+});
+
+test("Amounts.formatAmount passes through what it cannot parse", (t) => {
+ assert.strictEqual(Amounts.formatAmount("not an amount"), "not an amount");
+});
+
+test("Amounts.amountCurrency reads the currency off an amount", (t) => {
+ assert.strictEqual(Amounts.amountCurrency("CHF:12.50"), "CHF");
+ assert.strictEqual(
+ Amounts.amountCurrency({ currency: "EUR", value: 1, fraction: 0 }),
+ "EUR",
+ );
+ assert.strictEqual(Amounts.amountCurrency(undefined), "");
+ assert.strictEqual(Amounts.amountCurrency(null), "");
+ assert.strictEqual(Amounts.amountCurrency("nonsense"), "");
+});
+
diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts
@@ -800,6 +800,46 @@ export class Amounts {
return `${x} ${amount.currency}`;
}
+ /**
+ * Render an amount for a user to read.
+ *
+ * Formats parsed amounts as `CURRENCY VALUE` with at least 2 fractional digits
+ * (e.g., `CHF 12.50`).
+ *
+ * If amount is undefined, null, or empty string, returns "—".
+ * Unparseable strings are returned unchanged.
+ */
+ static formatAmount(amount: AmountLike | undefined | null): string {
+ if (!amount) return "—";
+ if (typeof amount === "string") {
+ const parsed = Amounts.parse(amount);
+ if (!parsed) return amount;
+ return `${parsed.currency} ${Amounts.stringifyValue(parsed, 2)}`;
+ }
+ try {
+ const parsed = Amounts.jsonifyAmount(amount);
+ return `${parsed.currency} ${Amounts.stringifyValue(parsed, 2)}`;
+ } catch {
+ return String(amount);
+ }
+ }
+
+ /**
+ * The currency of an amount, or the empty string if it has none or is invalid.
+ */
+ static amountCurrency(amount: AmountLike | undefined | null): string {
+ if (!amount) return "";
+ if (typeof amount === "string") {
+ return Amounts.parse(amount)?.currency ?? "";
+ }
+ try {
+ return Amounts.jsonifyAmount(amount).currency;
+ } catch {
+ return "";
+ }
+ }
+
+
static isSameCurrency(curr1: string, curr2: string): boolean {
return curr1.toLowerCase() === curr2.toLowerCase();
}
diff --git a/packages/taler-util/src/http-fake.test.ts b/packages/taler-util/src/http-fake.test.ts
@@ -0,0 +1,94 @@
+/*
+ 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 assert from "node:assert";
+import test from "node:test";
+import { HttpStatusCode, TalerErrorCode } from "./index.js";
+import {
+ FakeHttpLib,
+ conflict,
+ noContent,
+ notFound,
+ ok,
+ talerError,
+ unauthorized,
+} from "./http-fake.js";
+
+test("FakeHttpLib matches method and path suffix using .on()", async () => {
+ const http = new FakeHttpLib().on("GET", "/config", ok({ currency: "EUR" }));
+
+ const res = await http.fetch("https://example.com/api/v1/config");
+ assert.strictEqual(res.status, HttpStatusCode.Ok);
+ assert.deepStrictEqual(await res.json(), { currency: "EUR" });
+ assert.strictEqual(http.lastRequest?.url, "https://example.com/api/v1/config");
+ assert.strictEqual(http.lastRequest?.method, "GET");
+});
+
+test("FakeHttpLib fallback with .otherwise()", async () => {
+ const http = new FakeHttpLib().otherwise(noContent());
+
+ const res = await http.fetch("https://example.com/random/path");
+ assert.strictEqual(res.status, HttpStatusCode.NoContent);
+});
+
+test("FakeHttpLib custom handler with .handle()", async () => {
+ const http = new FakeHttpLib().handle((req) => {
+ if (req.url.includes("custom")) {
+ return ok({ handled: true });
+ }
+ return undefined;
+ });
+
+ const res = await http.fetch("https://example.com/custom");
+ assert.strictEqual(res.status, HttpStatusCode.Ok);
+ assert.deepStrictEqual(await res.json(), { handled: true });
+});
+
+test("FakeHttpLib throws explicitly on unscripted request", async () => {
+ const http = new FakeHttpLib();
+ await assert.rejects(
+ () => http.fetch("https://example.com/unscripted"),
+ /nothing scripted for GET/,
+ );
+});
+
+test("FakeHttpLib status helpers format responses correctly", async () => {
+ const http = new FakeHttpLib()
+ .on("GET", "/ok", ok({ msg: "hi" }))
+ .on("POST", "/nocontent", noContent())
+ .on("GET", "/unauthorized", unauthorized())
+ .on("GET", "/notfound", notFound())
+ .on("POST", "/conflict", conflict(talerError(TalerErrorCode.GENERIC_INVALID_RESPONSE, "custom err")));
+
+ const r1 = await http.fetch("https://example.com/ok");
+ assert.strictEqual(r1.status, HttpStatusCode.Ok);
+
+ const r2 = await http.fetch("https://example.com/nocontent", { method: "POST" });
+ assert.strictEqual(r2.status, HttpStatusCode.NoContent);
+
+ const r3 = await http.fetch("https://example.com/unauthorized");
+ assert.strictEqual(r3.status, HttpStatusCode.Unauthorized);
+
+ const r4 = await http.fetch("https://example.com/notfound");
+ assert.strictEqual(r4.status, HttpStatusCode.NotFound);
+
+ const r5 = await http.fetch("https://example.com/conflict", { method: "POST" });
+ assert.strictEqual(r5.status, HttpStatusCode.Conflict);
+ assert.deepStrictEqual(await r5.json(), {
+ code: TalerErrorCode.GENERIC_INVALID_RESPONSE,
+ hint: "custom err",
+ });
+});
diff --git a/packages/taler-util/src/http-fake.ts b/packages/taler-util/src/http-fake.ts
@@ -0,0 +1,149 @@
+/*
+ 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 { HttpStatusCode } from "./http-status-codes.js";
+import { TalerErrorCode } from "./taler-error-codes.js";
+import type {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http-common.js";
+
+/**
+ * A scripted HttpRequestLibrary.
+ *
+ * taler-util's clients take the HTTP library as a parameter, so a test can
+ * supply this and exercise the *real* client, the real codecs and the real
+ * failure handling — with no network, no fetch patching and no service worker.
+ */
+export interface Recorded {
+ method: string;
+ url: string;
+ body?: unknown;
+ headers?: Record<string, string>;
+}
+
+export type Handler = (req: Recorded) => HandlerResult | undefined;
+
+export interface HandlerResult {
+ status: number;
+ body?: unknown;
+}
+
+export class FakeHttpLib implements HttpRequestLibrary {
+ readonly requests: Recorded[] = [];
+ private handlers: Handler[] = [];
+ private fallback: HandlerResult | undefined;
+
+ /**
+ * Answer anything unscripted with `result` instead of throwing.
+ */
+ otherwise(result: HandlerResult): this {
+ this.fallback = result;
+ return this;
+ }
+
+ /** Answer requests matching `method` and a path suffix. */
+ on(method: string, pathSuffix: string, result: HandlerResult): this {
+ this.handlers.push((req) => {
+ if (req.method.toUpperCase() !== method.toUpperCase()) return undefined;
+ const path = new URL(req.url).pathname;
+ return path.endsWith(pathSuffix) ? result : undefined;
+ });
+ return this;
+ }
+
+ /** Full control, for cases the shorthand cannot express. */
+ handle(handler: Handler): this {
+ this.handlers.push(handler);
+ return this;
+ }
+
+ /** The last request, which is what a test usually wants to assert on. */
+ get lastRequest(): Recorded | undefined {
+ return this.requests[this.requests.length - 1];
+ }
+
+ async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ const recorded: Recorded = {
+ method: opt?.method ?? "GET",
+ url,
+ body: opt?.body,
+ headers: opt?.headers as Record<string, string> | undefined,
+ };
+ this.requests.push(recorded);
+
+ for (const h of this.handlers) {
+ const result = h(recorded);
+ if (result !== undefined) return makeResponse(url, result);
+ }
+ if (this.fallback !== undefined) return makeResponse(url, this.fallback);
+ throw Error(
+ `FakeHttpLib: nothing scripted for ${recorded.method} ${url}. ` +
+ `Add a handler with .on("${recorded.method}", "<path suffix>", …).`,
+ );
+ }
+}
+
+function makeResponse(url: string, result: HandlerResult): HttpResponse {
+ const body = result.body;
+ const text = body === undefined ? "" : JSON.stringify(body);
+ return {
+ requestUrl: url,
+ requestMethod: "GET",
+ status: result.status,
+ headers: {
+ get: (name: string): string | null =>
+ name.toLowerCase() === "content-type" && body !== undefined
+ ? "application/json"
+ : null,
+ } as HttpResponse["headers"],
+ json: async () => (body === undefined ? {} : body),
+ text: async () => text,
+ bytes: async () => new TextEncoder().encode(text),
+ };
+}
+
+/**
+ * A Taler error body.
+ */
+export function talerError(
+ code: TalerErrorCode = TalerErrorCode.MERCHANT_GENERIC_INSTANCE_UNKNOWN,
+ hint = "scripted failure",
+): Record<string, unknown> {
+ return { code, hint };
+}
+
+/** Shorthands for the statuses tests reach for most. */
+export const ok = (body?: unknown): HandlerResult => ({
+ status: HttpStatusCode.Ok,
+ body,
+});
+export const noContent = (): HandlerResult => ({
+ status: HttpStatusCode.NoContent,
+});
+export const unauthorized = (body?: unknown): HandlerResult => ({
+ status: HttpStatusCode.Unauthorized,
+ body: body ?? talerError(),
+});
+export const notFound = (body?: unknown): HandlerResult => ({
+ status: HttpStatusCode.NotFound,
+ body: body ?? talerError(),
+});
+export const conflict = (body?: unknown): HandlerResult => ({
+ status: HttpStatusCode.Conflict,
+ body: body ?? talerError(),
+});
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
@@ -63,6 +63,7 @@ export * from "./rfc3548.js";
export * from "./taler-crypto.js";
export * from "./taler_signatures.js";
export * from "./taleruri.js";
+export * from "./tan.js";
export { TaskThrottler } from "./TaskThrottler.js";
export * from "./time.js";
export * from "./timer.js";
diff --git a/packages/taler-util/src/payto.test.ts b/packages/taler-util/src/payto.test.ts
@@ -379,3 +379,33 @@ test("toFullString keeps the query parameters", (t) => {
assert.strictEqual(again.tag, "ok");
assert.strictEqual(Paytos.toFullString(Result.unpack(again)), full);
});
+
+test("Paytos helper functions extract fields and construct URIs correctly", () => {
+ const ibanPayto = Paytos.parsePaytoUri("payto://iban/DE75512108001245126199?receiver-name=Alice&receiver-town=Berlin&receiver-postal-code=10115");
+ assert.ok(ibanPayto);
+ assert.strictEqual(Paytos.getAccountHolder(ibanPayto), "Alice");
+ assert.strictEqual(Paytos.getReceiverTown(ibanPayto), "Berlin");
+ assert.strictEqual(Paytos.getReceiverPostalCode(ibanPayto), "10115");
+ assert.strictEqual(Paytos.getAccountNumber(ibanPayto), "DE75 5121 0800 1245 1261 99");
+
+ const bankPayto = Paytos.parsePaytoUri("payto://x-taler-bank/bank.example.com/bob?receiver-name=Bob");
+ assert.ok(bankPayto);
+ assert.strictEqual(Paytos.getAccountHolder(bankPayto), "Bob");
+ assert.strictEqual(Paytos.getBankHost(bankPayto), "bank.example.com");
+ assert.strictEqual(Paytos.getAccountNumber(bankPayto), "bob");
+
+ const constructedIban = Paytos.constructPayto({
+ targetType: "iban",
+ iban: "DE75512108001245126199",
+ accountHolder: "Charlie",
+ });
+ assert.strictEqual(constructedIban, "payto://iban/DE75512108001245126199?receiver-name=Charlie");
+
+ const constructedBank = Paytos.constructPayto({
+ targetType: "x-taler-bank",
+ bankHost: "bank.example.com",
+ accountName: "dave",
+ accountHolder: "Dave",
+ });
+ assert.strictEqual(constructedBank, "payto://x-taler-bank/bank.example.com/dave?receiver-name=Dave");
+});
diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts
@@ -58,6 +58,14 @@ export enum ReservePubParseError {
WRONG_LENGTH,
DECODE_ERROR,
}
+
+export interface PaytoInputFields {
+ targetType: "iban" | "x-taler-bank" | "upi" | "bitcoin" | string;
+ iban?: string;
+ bankHost?: string;
+ accountName?: string;
+ accountHolder?: string;
+}
declare const __hostport_str: unique symbol;
export type HostPortPath = string & { [__hostport_str]: true };
@@ -908,6 +916,128 @@ export namespace Paytos {
}
}
}
+
+ export function getAccountHolder(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri || !uri.params) return undefined;
+ return (
+ uri.params["receiver-name"] ||
+ uri.params["receiver"] ||
+ uri.params["account-name"] ||
+ uri.params["name"] ||
+ undefined
+ );
+ }
+
+ export function getReceiverPostalCode(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri || !uri.params) return undefined;
+ return (
+ uri.params["receiver-postal-code"] ||
+ uri.params["receiver-postcode"] ||
+ uri.params["postal-code"] ||
+ uri.params["postcode"] ||
+ undefined
+ );
+ }
+
+ export function getReceiverTown(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri || !uri.params) return undefined;
+ return (
+ uri.params["receiver-town"] ||
+ uri.params["receiver-city"] ||
+ uri.params["town"] ||
+ uri.params["city"] ||
+ undefined
+ );
+ }
+
+ export function getAccountNumber(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri) return undefined;
+ if (uri.targetType === PaytoType.IBAN) {
+ const rawIban = uri.iban || "";
+ const formattedIban = rawIban.replace(/(.{4})/g, "$1 ").trim();
+ return formattedIban || rawIban;
+ }
+ if (uri.targetType === PaytoType.TalerBank) {
+ return uri.account;
+ }
+ return uri.fullPath;
+ }
+
+ export function getBic(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri) return undefined;
+ if (uri.targetType === PaytoType.IBAN) {
+ return uri.bic || uri.params["bic"];
+ }
+ return uri.params["bic"];
+ }
+
+ export function getBankHost(
+ uri: URI | undefined | null,
+ ): string | undefined {
+ if (!uri) return undefined;
+ if (uri.targetType === PaytoType.TalerBank) {
+ return uri.host;
+ }
+ return undefined;
+ }
+
+ export function getTargetLabel(
+ uri: URI | undefined | null,
+ ): string {
+ if (!uri) return "Payout Account";
+ if (uri.targetType === PaytoType.IBAN) return "IBAN";
+ if (uri.targetType === PaytoType.TalerBank) return "Taler Bank";
+ const target = uri.targetType ?? (uri as PaytoUnsupported).target;
+ return target ? target.toUpperCase() : "Payout Account";
+ }
+
+ export function parsePaytoUri(rawUri?: string): URI | undefined {
+ if (!rawUri || !rawUri.trim()) return undefined;
+ const trimmed = rawUri.trim();
+ const res = fromString(trimmed, { allowUnsupported: true });
+ if (Result.isOk(res)) {
+ return res.value;
+ }
+ if (/^[A-Z]{2}[0-9]{2}/i.test(trimmed)) {
+ const ibanRes = parseIban(trimmed);
+ const canonicalIban = Result.isOk(ibanRes)
+ ? ibanRes.value
+ : (trimmed.replace(/\s+/g, "").toUpperCase() as IbanString);
+ return createIban(canonicalIban, undefined);
+ }
+ return createUnsupported("account", trimmed);
+ }
+
+ export function constructPayto(fields: PaytoInputFields): string {
+ const holder = fields.accountHolder?.trim();
+ const search: Record<string, string> = holder ? { "receiver-name": holder } : {};
+
+ if (fields.targetType === PaytoType.IBAN || fields.targetType === "iban") {
+ const cleanIban = (fields.iban || "").replace(/\s+/g, "").toUpperCase() as IbanString;
+ return toFullString(createIban(cleanIban, undefined, search));
+ }
+
+ if (fields.targetType === PaytoType.TalerBank || fields.targetType === "x-taler-bank") {
+ const hostStr = (fields.bankHost || "").trim().replace(/^https?:\/\//, "");
+ const host = parseHostPortPath2(hostStr, "") || (hostStr as HostPortPath);
+ const acc = (fields.accountName || "").trim();
+ return toFullString(createTalerBank(host, acc, search));
+ }
+
+ return toFullString(
+ createUnsupported(fields.targetType, fields.accountName || "", search),
+ );
+ }
}
export function codecForPaytoHash(): Codec<PaytoHash> {
diff --git a/packages/taler-util/src/tan.test.ts b/packages/taler-util/src/tan.test.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 { test } from "node:test";
+import assert from "node:assert";
+import { sanitizeTanCode, sanitizeMfaCode, formatTanDigits } from "./tan.js";
+
+test("sanitizeTanCode strips spaces, dashes, hyphens, non-alphanumeric chars and TM- prefix", () => {
+ assert.strictEqual(sanitizeTanCode(" 1234 - 5678 "), "12345678");
+ assert.strictEqual(sanitizeTanCode("TM-abcd-1234!"), "ABCD1234");
+ assert.strictEqual(sanitizeTanCode(" TM-1234-5678 "), "12345678");
+ assert.strictEqual(sanitizeTanCode("abc--def 123"), "ABCDEF123");
+ assert.strictEqual(sanitizeTanCode(""), "");
+ assert.strictEqual(sanitizeTanCode(null), "");
+ assert.strictEqual(sanitizeTanCode(undefined), "");
+});
+
+test("sanitizeMfaCode works as an alias to sanitizeTanCode", () => {
+ assert.strictEqual(sanitizeMfaCode(" TM-9999-8888 "), "99998888");
+});
+
+test("formatTanDigits formats sanitized TAN codes with a hyphen after 4 digits", () => {
+ assert.strictEqual(formatTanDigits("12345678"), "1234-5678");
+ assert.strictEqual(formatTanDigits("1234"), "1234");
+ assert.strictEqual(formatTanDigits("123"), "123");
+ assert.strictEqual(formatTanDigits(" TM-1234 5678 "), "1234-5678");
+ assert.strictEqual(formatTanDigits(null), "");
+});
diff --git a/packages/taler-util/src/tan.ts b/packages/taler-util/src/tan.ts
@@ -0,0 +1,46 @@
+/*
+ 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/>
+ */
+
+/**
+ * Sanitizes a 2FA / MFA / TAN code by stripping leading TM- prefix,
+ * spaces, dashes, hyphens, and all non-alphanumeric characters,
+ * returning uppercase alphanumeric characters.
+ */
+export function sanitizeTanCode(input: string | null | undefined): string {
+ if (!input) return "";
+ return input
+ .replace(/[\s-]/g, "")
+ .toUpperCase()
+ .replace(/^TM-?/, "")
+ .replace(/[^A-Z0-9]/g, "");
+}
+
+/**
+ * Alias for sanitizeTanCode to sanitize MFA codes.
+ */
+export const sanitizeMfaCode = sanitizeTanCode;
+
+/**
+ * Formats a TAN digit input for display by sanitizing it and adding a hyphen
+ * after the first 4 characters if longer than 4 characters.
+ */
+export function formatTanDigits(input: string | null | undefined): string {
+ const clean = sanitizeTanCode(input);
+ if (clean.length > 4) {
+ return `${clean.slice(0, 4)}-${clean.slice(4, 8)}`;
+ }
+ return clean;
+}
diff --git a/packages/taler-util/src/time.test.ts b/packages/taler-util/src/time.test.ts
@@ -17,6 +17,7 @@
import { test } from "node:test";
import assert from "node:assert";
import {
+ AbsoluteTime,
codecForAbsoluteTime,
codecForDuration,
codecForDurationMs,
@@ -91,3 +92,58 @@ test("time codecs accept the documented values", (t) => {
{ t_s: 3, off_us: 4 },
);
});
+
+test("Duration.formatShort formats durations correctly", (t) => {
+ assert.strictEqual(Duration.formatShort({ d_us: 30 * 60 * 1_000_000 }), "30m");
+ assert.strictEqual(Duration.formatShort({ d_us: 2 * 3600 * 1_000_000 }), "2h");
+ assert.strictEqual(Duration.formatShort({ d_us: 7 * 86400 * 1_000_000 }), "7d");
+ assert.strictEqual(Duration.formatShort({ d_us: "forever" }), "Forever");
+ assert.strictEqual(Duration.formatShort({ d_ms: 60000 }), "1m");
+ assert.strictEqual(Duration.formatShort(undefined), undefined);
+ assert.strictEqual(Duration.formatShort({ d_us: 0 }), undefined);
+});
+
+test("AbsoluteTime.parseTimestampMs parses TimestampLike values correctly", (t) => {
+ assert.strictEqual(AbsoluteTime.parseTimestampMs({ t_s: 1700000000 }), 1700000000000);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs({ t_s: "never" }), undefined);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs({ t_ms: 1700000000123 }), 1700000000123);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs(1700000000), 1700000000000);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs(1700000000123), 1700000000123);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs("never"), undefined);
+ assert.strictEqual(AbsoluteTime.parseTimestampMs(undefined), undefined);
+});
+
+test("AbsoluteTime.formatDate formats dates according to pattern and options", (t) => {
+ const ts = new Date(2026, 7, 8, 14, 30, 45).getTime(); // 2026-08-08 14:30:45
+ assert.strictEqual(AbsoluteTime.formatDate(ts, { dateFormat: "ymd" }), "2026/08/08");
+ assert.strictEqual(AbsoluteTime.formatDate(ts, { dateFormat: "dmy" }), "08/08/2026");
+ assert.strictEqual(AbsoluteTime.formatDate(ts, { dateFormat: "mdy" }), "08/08/2026");
+ assert.strictEqual(
+ AbsoluteTime.formatDate(ts, { dateFormat: "ymd", includeTime: true }),
+ "2026/08/08 14:30",
+ );
+ assert.strictEqual(
+ AbsoluteTime.formatDate(ts, { dateFormat: "ymd", includeTime: true, includeSeconds: true }),
+ "2026/08/08 14:30:45",
+ );
+ assert.strictEqual(AbsoluteTime.formatDate(null), "—");
+});
+
+test("AbsoluteTime.formatRelativeTime calculates relative time accurately", (t) => {
+ const baseMs = 1700000000000;
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 5000), "just now");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 20000), "< 30s ago");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 45000), "< 1m ago");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 120000), "2m ago");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 3600000), "1h ago");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(baseMs, baseMs + 86400000 * 3), "3d ago");
+ assert.strictEqual(AbsoluteTime.formatRelativeTime(null), "");
+});
+
+test("AbsoluteTime.formatTimestamp handles fallback and never values", (t) => {
+ assert.strictEqual(AbsoluteTime.formatTimestamp({ t_s: "never" }), "Never");
+ 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
@@ -185,6 +185,26 @@ export interface TalerProtocolDuration {
readonly d_us: number | "forever";
}
+export type DurationLike = TalerProtocolDuration | Duration;
+
+export type TimestampLike =
+ | AbsoluteTime
+ | { t_ms: number | "never" }
+ | TalerProtocolTimestamp
+ | TalerPreciseTimestamp
+ | Date
+ | number
+ | string
+ | Record<string, unknown>;
+
+export type DateFormatPattern = "ymd" | "dmy" | "mdy";
+
+export interface FormatDateOptions {
+ dateFormat?: DateFormatPattern;
+ includeTime?: boolean;
+ includeSeconds?: boolean;
+}
+
/**
* Timeshift in milliseconds.
*/
@@ -458,6 +478,33 @@ export namespace Duration {
}): Duration {
return durationMax(durationMin(args.value, args.upper), args.lower);
}
+
+ /**
+ * Render a Duration or TalerProtocolDuration as a short duration string
+ * ("30m", "2h", "7d", "Forever"). Returns undefined when unset or <= 0.
+ */
+ export function formatShort(
+ 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" : duration.d_ms * 1000;
+ } else {
+ return undefined;
+ }
+ if (us === "forever") return "Forever";
+ if (typeof us !== "number" || !isFinite(us) || us <= 0) return undefined;
+ const MINUTE_US = 60 * 1_000_000;
+ const HOUR_US = 60 * MINUTE_US;
+ const DAY_US = 24 * HOUR_US;
+ if (us % DAY_US === 0) return `${us / DAY_US}d`;
+ if (us % HOUR_US === 0) return `${us / HOUR_US}h`;
+ if (us % MINUTE_US === 0) return `${us / MINUTE_US}m`;
+ return `${Math.round(us / MINUTE_US)}m`;
+ }
}
export namespace AbsoluteTime {
@@ -681,6 +728,150 @@ export namespace AbsoluteTime {
}
return new Date(t.t_ms).toISOString();
}
+
+ /**
+ * Parse a TimestampLike value (AbsoluteTime, TalerProtocolTimestamp,
+ * TalerPreciseTimestamp, Date, number, or ISO string) into a millisecond timestamp.
+ * Returns undefined if nullish, invalid, or "never".
+ */
+ export function parseTimestampMs(
+ val: TimestampLike | undefined | null,
+ ): number | undefined {
+ if (!val) return undefined;
+ if (typeof val === "number") return val > 1e11 ? val : val * 1000;
+ if (val instanceof Date) return isNaN(val.getTime()) ? undefined : val.getTime();
+ if (typeof val === "string") {
+ if (val === "never") return undefined;
+ const parsed = Date.parse(val);
+ return isNaN(parsed) ? undefined : parsed;
+ }
+ if (typeof val === "object") {
+ const v = val as Record<string, unknown>;
+ if (v.t_s === "never" || v.t_ms === "never") return undefined;
+ if (typeof v.t_ms === "number") return v.t_ms;
+ if (typeof v.t_s === "number") {
+ const offUs = typeof v.off_us === "number" ? v.off_us : 0;
+ return v.t_s * 1000 + Math.floor(offUs / 1000);
+ }
+ if (typeof v.d_ms === "number") return v.d_ms;
+ if (typeof v.d_us === "number") return Math.floor(v.d_us / 1000);
+ }
+ return undefined;
+ }
+
+ /**
+ * Format a TimestampLike value according to a date format pattern ("ymd", "dmy", "mdy")
+ * and optional time options.
+ */
+ export function formatDate(
+ input: TimestampLike | null | undefined,
+ options?: FormatDateOptions,
+ ): string {
+ if (input === null || input === undefined || input === "") return "—";
+
+ const ms = parseTimestampMs(input);
+ if (ms === undefined) {
+ return String(input);
+ }
+
+ const d = new Date(ms);
+ const yyyy = String(d.getFullYear());
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
+ const dd = String(d.getDate()).padStart(2, "0");
+
+ let datePart: string;
+ const format = options?.dateFormat || "ymd";
+ switch (format) {
+ case "dmy":
+ datePart = `${dd}/${mm}/${yyyy}`;
+ break;
+ case "mdy":
+ datePart = `${mm}/${dd}/${yyyy}`;
+ break;
+ case "ymd":
+ default:
+ datePart = `${yyyy}/${mm}/${dd}`;
+ break;
+ }
+
+ if (!options?.includeTime) {
+ return datePart;
+ }
+
+ const hh = String(d.getHours()).padStart(2, "0");
+ const min = String(d.getMinutes()).padStart(2, "0");
+ if (options.includeSeconds) {
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ return `${datePart} ${hh}:${min}:${ss}`;
+ }
+
+ return `${datePart} ${hh}:${min}`;
+ }
+
+ /**
+ * Parse a TimestampLike value into a valid Date object, or null if empty or unparseable.
+ */
+ export function toDate(input: TimestampLike | null | undefined): Date | null {
+ const ms = parseTimestampMs(input);
+ if (ms !== undefined) return new Date(ms);
+ if (typeof input === "string" && input.includes(" ")) {
+ const d = new Date(input.replace(" ", "T"));
+ return isNaN(d.getTime()) ? null : d;
+ }
+ return null;
+ }
+
+ /**
+ * Format a past date or timestamp as a concise relative time string (e.g. "just now", "< 30s ago", "2m ago", "1h ago").
+ */
+ export function formatRelativeTime(
+ input: TimestampLike | null | undefined,
+ nowMs = Date.now(),
+ ): string {
+ const d = toDate(input);
+ if (!d) return "";
+
+ const diffSec = Math.max(0, Math.floor((nowMs - d.getTime()) / 1000));
+ if (diffSec < 10) return "just now";
+ if (diffSec < 30) return "< 30s ago";
+ if (diffSec < 60) return "< 1m ago";
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return `${diffMin}m ago`;
+ const diffHours = Math.floor(diffMin / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ return `${diffDays}d ago`;
+ }
+
+ /**
+ * Convenience helper to format date with hours and minutes according to options.
+ */
+ export function formatDateTime(
+ input: TimestampLike | null | undefined,
+ options?: FormatDateOptions,
+ ): string {
+ return formatDate(input, { ...options, includeTime: true });
+ }
+
+ /**
+ * Format a TimestampLike value according to options and a fallback for empty/never values.
+ */
+ export function formatTimestamp(
+ val: TimestampLike | undefined | null,
+ fallback = "N/A",
+ options?: FormatDateOptions,
+ ): string {
+ if (!val) return fallback;
+ if (val === "never" || (typeof val === "object" && "t_s" in val && val.t_s === "never")) {
+ return "Never";
+ }
+ const ms = parseTimestampMs(val);
+ if (ms !== undefined && !isNaN(ms)) {
+ return formatDateTime(ms, options);
+ }
+ if (typeof val === "string") return val;
+ return fallback;
+ }
}
const SECONDS = 1000;