taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-util/src/amounts.test.ts | 35+++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/amounts.ts | 40++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/http-fake.test.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/http-fake.ts | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/index.ts | 1+
Mpackages/taler-util/src/payto.test.ts | 30++++++++++++++++++++++++++++++
Mpackages/taler-util/src/payto.ts | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/tan.test.ts | 41+++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/tan.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/time.test.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/time.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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;