taler-typescript-core

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

commit a86844a61d90afcc76852e4ec5d7967043f497e7
parent 61adc575d1d9a61db305b66d030d6cd1049d8eba
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 18:21:50 +0200

merchant web UI: show live exchange status alongside configuration

Combine configured payment services with exchange key-download results.
Show status details, expiration warnings, and unavailable or stale data.
Poll while the screen is open and keep the menu visible to all users.

Issue: https://bugs.taler.net/n/11026

Diffstat:
Mpackages/taler-merchant-webui/README.md | 5+++++
Apackages/taler-merchant-webui/contrib/qa/exchange-status-check.mjs | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/package.json | 1+
Apackages/taler-merchant-webui/src/api/exchangeStatus.test.ts | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/api/exchangeStatus.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/api/hooks/useExchangeStatus.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/routes/PaymentServicesRoute.tsx | 10+++-------
Mpackages/taler-merchant-webui/src/screens/PaymentServicesScreen.tsx | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Apackages/taler-merchant-webui/src/screens/paymentServices.test.tsx | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/screens/paymentServices.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/stories/story-data.tsx | 20+++++++++++++++++++-
Mpackages/taler-merchant-webui/src/tutorial/tutorialData.tsx | 25+++++++++++++++++++++++--
12 files changed, 1218 insertions(+), 104 deletions(-)

diff --git a/packages/taler-merchant-webui/README.md b/packages/taler-merchant-webui/README.md @@ -70,6 +70,11 @@ pnpm --filter @gnu-taler/taler-merchant-webui lint pnpm --filter @gnu-taler/taler-merchant-webui visual:compare ``` +The live exchange-status route has an additional browser check for polling, +request failures, navigation, and desktop/mobile details. After `pnpm build`, +run `pnpm test:exchange-status-browser` from this package. It uses a local mock +backend and `/usr/bin/google-chrome` (override with `CHROME_BIN`). + Web PoS-specific layout constraints are in [`src/screens/pos/README.md`](src/screens/pos/README.md); visual baseline review is in [`visual/README.md`](visual/README.md). diff --git a/packages/taler-merchant-webui/contrib/qa/exchange-status-check.mjs b/packages/taler-merchant-webui/contrib/qa/exchange-status-check.mjs @@ -0,0 +1,267 @@ +/* + 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. + */ + +// Run from the package root after building. The Node DOM setup suppresses SWR +// effects, so this test exercises the real route and SWR in Chrome instead. +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { build } from "esbuild"; +import puppeteer from "puppeteer-core"; +import { buildOptions } from "../../esbuild.config.mjs"; + +const bundle = await build({ + ...buildOptions({ outdir: "unused", dev: true }), + entryPoints: undefined, + stdin: { + contents: ` + import { render } from 'preact'; + import { PaymentServicesRoute } from './src/routes/PaymentServicesRoute.tsx'; + import { webUiConfig } from './src/stores/webuiConfig.ts'; + import { session } from './src/stores/session.ts'; + session.value = { account: 'shop' }; + window.mount = (backend = 'a') => { + webUiConfig.value = { merchant_base_url: location.origin + '/backend-' + backend + '/' }; + render(<PaymentServicesRoute />, document.getElementById('app')); + }; + window.unmount = () => render(null, document.getElementById('app')); + window.mount(); + `, + resolveDir: process.cwd(), + loader: "tsx", + }, + splitting: false, + sourcemap: false, + write: false, +}); +const script = bundle.outputFiles.find((f) => f.path.endsWith(".js")).text; +const style = readFileSync("dist/prod/style.css"); +const configuration = { + version: "42:0:30", + name: "taler-merchant", + currency: "CHF", + currencies: {}, + exchanges: [ + { + base_url: "https://payments.example.ch/", + currency: "CHF", + master_pub: "0".repeat(52), + }, + ], + default_persona: "expert", + report_generators: [], + have_donau: false, + have_self_provisioning: false, + mandatory_tan_channels: [], + payment_target_types: "*", +}; +const status = { + exchange_url: "https://payments.example.ch/", + keys_http_status: 200, + keys_ec: 0, + keys_hint: "", + keys_expiration: { t_s: 1 }, + next_download: { t_s: 1 }, +}; +const requests = []; +const errors = []; +let failStatus = false; +let hold = false; +const held = []; +const server = createServer((req, res) => { + const url = req.url; + function json(code, body) { + res.writeHead(code, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + } + if (url.endsWith("/config")) { + requests.push(url); + return json(200, configuration); + } + if (url.endsWith("/exchanges")) { + requests.push(url); + const reply = () => + failStatus + ? json(500, { code: 1, hint: "Status temporarily unavailable" }) + : json(200, { + exchanges: [ + { + ...status, + ...(url.includes("backend-b") + ? { + keys_http_status: 502, + keys_ec: 2010, + keys_hint: "Backend B error", + } + : {}), + }, + ], + }); + if (hold && url.includes("backend-a")) held.push(reply); + else reply(); + return; + } + if (url === "/bundle.js") { + res.writeHead(200, { "content-type": "application/javascript" }); + res.end(script); + } else if (url === "/style.css") { + res.writeHead(200, { "content-type": "text/css" }); + res.end(style); + } else { + res.writeHead(200, { "content-type": "text/html" }); + res.end( + '<html lang="en"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/style.css"><div id="app"></div><script type="module" src="/bundle.js"></script></html>', + ); + } +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +let browser; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const count = (backend) => + requests.filter((url) => url === `/backend-${backend}/exchanges`).length; +try { + browser = await puppeteer.launch({ + executablePath: process.env.CHROME_BIN || "/usr/bin/google-chrome", + headless: true, + args: ["--no-sandbox", "--disable-gpu"], + }); + const page = await browser.newPage(); + page.on("pageerror", (error) => errors.push(error.message)); + await page.setViewport({ width: 1280, height: 1000 }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.waitForFunction( + () => + document.body.textContent.includes("UP") && + document.body.textContent.includes("CHF"), + ); + assert.equal(count("a"), 1, "one initial status request"); + assert.ok(requests.includes("/backend-a/config")); + assert.match( + await page.$eval("table", (el) => el.textContent), + /keys have expired/, + ); + const details = await page.$("table td:last-child summary"); + await details.focus(); + await page.keyboard.press("Enter"); + assert.equal( + await page.$eval("table td:last-child details", (el) => el.open), + true, + ); + await page.screenshot({ path: "/tmp/merchant-status-desktop.png" }); + await page.setViewport({ width: 390, height: 844 }); + await page.click("article dd summary"); + assert.equal(await page.$eval("article dd details", (el) => el.open), true); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); + await page.screenshot({ path: "/tmp/merchant-status-mobile.png" }); + + const beforePoll = count("a"); + await delay(5300); + assert.ok( + count("a") > beforePoll, + "route must poll without user interaction", + ); + hold = true; + const beforeFocus = count("a"); + await page.evaluate(() => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("focus")); + }); + await delay(200); + assert.equal( + count("a"), + beforeFocus + 1, + "focus and reconnect share the in-flight request", + ); + hold = false; + for (const reply of held.splice(0)) reply(); + await delay(150); + + failStatus = true; + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await page.waitForFunction(() => + document.body.textContent.includes("last known results"), + ); + assert.match( + await page.$eval("article", (el) => el.textContent), + /CHF[\s\S]*UP[\s\S]*Last known status/, + ); + const failedCount = count("a"); + await delay(5300); + assert.equal( + count("a"), + failedCount, + "failure uses five minutes, not stale overdue timestamp", + ); + failStatus = false; + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await page.waitForFunction( + () => !document.body.textContent.includes("last known results"), + ); + + hold = true; + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await delay(150); + await page.evaluate(() => window.mount("b")); + await page.waitForFunction(() => + document.body.textContent.includes("Backend B error"), + ); + hold = false; + for (const reply of held.splice(0)) reply(); + const oldBackendCount = count("a"); + await delay(5300); + assert.equal( + count("a"), + oldBackendCount, + "backend switch disposes the old timer", + ); + assert.match(await page.$eval("article", (el) => el.textContent), /DOWN/); + assert.doesNotMatch( + await page.$eval("article", (el) => el.textContent), + /\bUP\b/, + ); + + await page.evaluate(() => window.unmount()); + const stoppedCount = count("b"); + await delay(5300); + await page.evaluate(() => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("online")); + }); + await delay(150); + assert.equal( + count("b"), + stoppedCount, + "unmount removes polling and event listeners", + ); + await page.evaluate(() => window.mount("b")); + await page.waitForFunction(() => + document.body.textContent.includes("Backend B error"), + ); + await delay(150); + assert.equal( + count("b"), + stoppedCount + 1, + "returning to the screen refreshes cached status", + ); + assert.ok(requests.every((url) => !url.includes("/instances/"))); + assert.deepEqual(errors, []); + console.log( + "Exchange status browser checks passed: polling, partial failure, recovery, deduplication, lifecycle, desktop and mobile.", + ); +} finally { + for (const reply of held.splice(0)) reply(); + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json @@ -12,6 +12,7 @@ "compile": "tsc && ./build.mjs", "dev": "./dev.mjs", "test": "./test.mjs", + "test:exchange-status-browser": "node contrib/qa/exchange-status-check.mjs", "test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant-webui", "lint": "../qa-tooling/bin/eslint.mjs .", "i18n:source2po": "pogen extract && pogen merge", diff --git a/packages/taler-merchant-webui/src/api/exchangeStatus.test.ts b/packages/taler-merchant-webui/src/api/exchangeStatus.test.ts @@ -0,0 +1,194 @@ +/* + 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. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createExchangeStatusPoller, + exchangeStatusRefreshDelay, + readExchangeStatus, + type ExchangeStatus, +} from "./exchangeStatus.js"; +import { FakeHttpLib, ok } from "../testing/fake-http.js"; + +const status: ExchangeStatus = { + exchange_url: "https://exchange.example/", + next_download: { t_s: 1020 }, + keys_expiration: { t_s: 2000 }, + keys_http_status: 200, + keys_ec: 0, + keys_hint: "Success", +}; +const flush = () => new Promise<void>((resolve) => setImmediate(resolve)); + +test("exchange status uses the backend root for a non-default merchant and decodes the response", async () => { + const http = new FakeHttpLib().on( + "GET", + "/exchanges", + ok({ exchanges: [status] }), + ); + assert.deepEqual( + await readExchangeStatus({ + rootUrl: new URL("https://merchant.example/base/"), + account: "shop", + http, + }), + [status], + ); + assert.equal( + http.lastRequest?.url, + "https://merchant.example/base/exchanges", + ); +}); + +test("status protocol failures are not interpreted as empty exchange lists", async () => { + const http = new FakeHttpLib().on( + "GET", + "/exchanges", + ok({ exchanges: [{ exchange_url: status.exchange_url }] }), + ); + await assert.rejects( + readExchangeStatus({ rootUrl: new URL("https://merchant.example/"), http }), + ); +}); + +test("refresh timing uses the earliest download with bounds and never fallback", () => { + const now = 1_000_000; + assert.equal(exchangeStatusRefreshDelay([status], now), 25_000); + assert.equal( + exchangeStatusRefreshDelay( + [status, { ...status, next_download: { t_s: 1010 } }], + now, + ), + 15_000, + ); + assert.equal( + exchangeStatusRefreshDelay( + [{ ...status, next_download: { t_s: 900 } }], + now, + ), + 5000, + ); + assert.equal( + exchangeStatusRefreshDelay( + [{ ...status, next_download: { t_s: 1000 } }], + now, + ), + 5000, + ); + for (const statuses of [ + undefined, + [], + [{ ...status, next_download: { t_s: "never" as const } }], + [{ ...status, next_download: { t_s: 999999 } }], + ]) { + assert.equal(exchangeStatusRefreshDelay(statuses, now), 300_000); + } +}); + +test("poller shares in-flight refreshes, schedules after settlement, and stops on disposal", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1_000_000 }); + let calls = 0; + let resolve!: (value: ExchangeStatus[]) => void; + const poller = createExchangeStatusPoller(() => { + calls++; + return new Promise((r) => { + resolve = r; + }); + }); + t.after(() => poller.dispose()); + const initial = poller.refresh(); + assert.equal(initial, poller.refresh()); + await flush(); + t.mock.timers.tick(10_000); + assert.equal(calls, 1); + resolve([status]); + await initial; + t.mock.timers.tick(14_999); + await flush(); + assert.equal(calls, 1); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, 2); + poller.dispose(); + resolve([status]); + await flush(); + t.mock.timers.tick(600_000); + await poller.refresh(); + assert.equal(calls, 2); +}); + +test("failed polls keep retrying every five minutes and recover", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1_000_000 }); + let calls = 0; + const poller = createExchangeStatusPoller(async () => { + if (++calls <= 7) throw new Error("offline"); + return [{ ...status, next_download: { t_s: 0 } }]; + }); + t.after(() => poller.dispose()); + await poller.refresh(); + for (let i = 1; i <= 7; i++) { + t.mock.timers.tick(299_999); + await flush(); + assert.equal(calls, i); + t.mock.timers.tick(1); + await flush(); + assert.equal(calls, i + 1); + } + t.mock.timers.tick(5000); + await flush(); + assert.equal(calls, 9); +}); + +test("manual refresh replaces the scheduled poll", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1_000_000 }); + let calls = 0; + const poller = createExchangeStatusPoller(async () => { + calls++; + return []; + }); + t.after(() => poller.dispose()); + await poller.refresh(); + t.mock.timers.tick(200_000); + await poller.refresh(); + t.mock.timers.tick(100_000); + await flush(); + assert.equal(calls, 2); + poller.dispose(); + t.mock.timers.tick(600_000); + await flush(); + assert.equal(calls, 2); +}); + +test("disposal before a queued refresh prevents the request", async () => { + let calls = 0; + const poller = createExchangeStatusPoller(async () => { + calls++; + return []; + }); + const pending = poller.refresh(); + poller.dispose(); + await pending; + assert.equal(calls, 0); +}); + +test("unsupported and failed status endpoints remain errors", async () => { + for (const status of [404, 500]) { + const http = new FakeHttpLib().on("GET", "/exchanges", { + status, + body: { code: 1, hint: "Unavailable" }, + }); + await assert.rejects( + readExchangeStatus({ + rootUrl: new URL("https://merchant.example/"), + http, + }), + ); + } +}); diff --git a/packages/taler-merchant-webui/src/api/exchangeStatus.ts b/packages/taler-merchant-webui/src/api/exchangeStatus.ts @@ -0,0 +1,76 @@ +/* + 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. + */ + +import { type TalerMerchantApi } from "@gnu-taler/taler-util"; +import { merchantManagementClient, type BackendConfig } from "./client.js"; +import { unwrap } from "./failure.js"; + +export type ExchangeStatus = TalerMerchantApi.ExchangeStatusDetail; +export const EXCHANGE_STATUS_MAX_DELAY = 5 * 60 * 1000; + +export async function readExchangeStatus( + config: BackendConfig, +): Promise<ExchangeStatus[]> { + return unwrap(await merchantManagementClient(config).listExchanges()) + .exchanges; +} + +/** Leave the updater five seconds to finish, and never spin on an overdue date. */ +export function exchangeStatusRefreshDelay( + statuses: ExchangeStatus[] | undefined, + now = Date.now(), +): number { + let delay = EXCHANGE_STATUS_MAX_DELAY; + for (const status of statuses ?? []) { + const seconds = status.next_download.t_s; + if (typeof seconds === "number" && Number.isFinite(seconds)) { + delay = Math.min(delay, Math.max(5000, seconds * 1000 + 5000 - now)); + } + } + return delay; +} + +/** One request at a time, including manual refresh and focus/reconnect events. */ +export function createExchangeStatusPoller( + request: () => Promise<ExchangeStatus[] | undefined>, +) { + let disposed = false; + let timer: ReturnType<typeof setTimeout> | undefined; + let pending: Promise<void> | undefined; + + function refresh(): Promise<void> { + if (disposed) return Promise.resolve(); + if (pending) return pending; + clearTimeout(timer); + pending = Promise.resolve().then(async () => { + if (disposed) { + pending = undefined; + return; + } + let delay = EXCHANGE_STATUS_MAX_DELAY; + try { + delay = exchangeStatusRefreshDelay(await request()); + } catch { + // The resource owns the error display. Keep retrying even after failures. + } finally { + pending = undefined; + if (!disposed) timer = setTimeout(() => void refresh(), delay); + } + }); + return pending; + } + + return { + refresh, + dispose() { + disposed = true; + clearTimeout(timer); + }, + }; +} diff --git a/packages/taler-merchant-webui/src/api/hooks/useExchangeStatus.ts b/packages/taler-merchant-webui/src/api/hooks/useExchangeStatus.ts @@ -0,0 +1,76 @@ +/* + 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. + */ + +import { useEffect, useMemo, useRef } from "preact/hooks"; +import { useMerchantSWR } from "../swr.js"; +import { remoteResource } from "../contracts.js"; +import { getClientConfig } from "./common.js"; +import { + createExchangeStatusPoller, + readExchangeStatus, + type ExchangeStatus, +} from "../exchangeStatus.js"; + +/** Mounted only by the payment-services route; configuration is fetched separately. */ +export function useExchangeStatus() { + const config = getClientConfig(); + // SWR's mutate() can resolve to cached data after a failed revalidation. Track + // the actual fetch outcome so that stale next_download values cannot drive retries. + const outcome = useMemo( + () => ({ + key: ["getExchangeStatus", config.rootUrl.href], + latest: undefined as ExchangeStatus[] | undefined, + }), + [config.rootUrl.href], + ); + const swr = useMerchantSWR( + outcome.key, + async () => { + try { + return (outcome.latest = await readExchangeStatus(config)); + } catch (error) { + outcome.latest = undefined; + throw error; + } + }, + { + revalidateOnMount: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }, + ); + const { mutate } = swr; + const poller = useRef<ReturnType<typeof createExchangeStatusPoller>>(); + useEffect(() => { + const active = createExchangeStatusPoller(async () => { + await mutate(); + return outcome.latest; + }); + poller.current = active; + const refresh = () => { + void active.refresh(); + }; + refresh(); + window.addEventListener("focus", refresh); + window.addEventListener("online", refresh); + return () => { + active.dispose(); + window.removeEventListener("focus", refresh); + window.removeEventListener("online", refresh); + if (poller.current === active) poller.current = undefined; + }; + }, [mutate, outcome]); + + return { + ...remoteResource(swr), + isLoading: swr.data === undefined && !swr.error, + refresh: () => poller.current?.refresh() ?? Promise.resolve(), + }; +} diff --git a/packages/taler-merchant-webui/src/routes/PaymentServicesRoute.tsx b/packages/taler-merchant-webui/src/routes/PaymentServicesRoute.tsx @@ -16,16 +16,11 @@ import type { VNode } from "preact"; import { mapRemoteResource, useMerchantConfig } from "../api/hooks.js"; +import { useExchangeStatus } from "../api/hooks/useExchangeStatus.js"; import { PaymentServicesScreen } from "../screens/PaymentServicesScreen.js"; -/** - * The payment services this server accepts coins from. - * - * The screen used to be mounted with no props at all, so every merchant was - * shown the same two invented services belonging to nobody. The real list is - * the `exchanges` array of the merchant's own `/config`. - */ export function PaymentServicesRoute(): VNode { + const statusResource = useExchangeStatus(); const { exchanges, isLoading, resource } = useMerchantConfig(); const exchangesResource = mapRemoteResource( resource, @@ -36,6 +31,7 @@ export function PaymentServicesRoute(): VNode { exchanges={exchanges || []} isLoading={isLoading} exchangesResource={exchangesResource} + statusResource={statusResource} /> ); } diff --git a/packages/taler-merchant-webui/src/screens/PaymentServicesScreen.tsx b/packages/taler-merchant-webui/src/screens/PaymentServicesScreen.tsx @@ -15,30 +15,30 @@ */ import type { VNode } from "preact"; +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { preferences } from "../stores/preferences.js"; import { Header } from "../ui/Header.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; import type { RemoteResource } from "../api/contracts.js"; +import type { ExchangeStatus } from "../api/exchangeStatus.js"; import { useTranslation } from "../context/translation.js"; +import { + exchangeHealth, + exchangeKeysExpired, + mergePaymentServices, + type ExchangeServiceItem, + type PaymentService, +} from "./paymentServices.js"; -/** - * One payment service, exactly as `/config` lists it. - * - * There is nothing else to have: the response carries the address, the - * currency and the identifier and no more, so whether the service is reachable - * and whether the merchant's account with it is ready cannot be shown here. - * That is recorded as a gap in reports/backend-requirements.md. - */ -export interface ExchangeServiceItem { - base_url: string; - currency: string; - master_pub: string; -} +export type { ExchangeServiceItem } from "./paymentServices.js"; export interface PaymentServicesScreenProps { exchanges?: ExchangeServiceItem[]; isLoading?: boolean; error?: string; exchangesResource?: RemoteResource<ExchangeServiceItem[]>; + statuses?: ExchangeStatus[]; + statusResource?: RemoteResource<ExchangeStatus[]>; } function serviceName(baseUrl: string): string { @@ -49,14 +49,124 @@ function serviceName(baseUrl: string): string { } } +function ServiceStatus({ + status, + loading, + stale, +}: { + status?: ExchangeStatus; + loading: boolean; + stale: boolean; +}): VNode { + const { t } = useTranslation(); + const health = exchangeHealth(status); + if (!status) { + return ( + <span class="text-gray-600"> + {loading ? t`Loading status…` : t`Status unavailable`} + </span> + ); + } + const expired = exchangeKeysExpired(status); + const expiration = status.keys_expiration; + const dateOptions = { dateFormat: preferences.value.dateFormat }; + const expirationText = + expiration?.t_s === "never" + ? t`Never` + : AbsoluteTime.formatTimestamp(expiration, "—", dateOptions); + const nextDownload = + status.next_download.t_s === "never" + ? t`Not scheduled` + : AbsoluteTime.formatTimestamp(status.next_download, "—", dateOptions); + return ( + <div class="space-y-2"> + <span + class={`inline-block rounded px-2 py-1 text-xs font-bold ${stale ? "bg-amber-100 text-amber-900" : health === "up" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`} + > + {health === "up" ? t`UP` : t`DOWN`} + {stale && <> · {t`Last known status`}</>} + </span> + {health === "up" && expired && ( + <p class="text-xs font-semibold text-amber-900">{t`The downloaded keys have expired. Contact your provider if this persists.`}</p> + )} + {health === "up" && !expiration && ( + <p class="text-xs text-amber-900">{t`Key expiration is unavailable.`}</p> + )} + <details class="text-xs"> + <summary class="cursor-pointer font-semibold text-blue-700">{t`Status details`}</summary> + <div class="mt-2 space-y-2 break-words"> + {health === "up" ? ( + expiration && <p>{t`Ready until: ${expirationText}`}</p> + ) : ( + <> + <p>{t`Last HTTP status was ${status.keys_http_status}: ${status.keys_hint} (#${status.keys_ec})`}</p> + {status.keys_http_status === 0 && ( + <p>{t`No HTTP response was received.`}</p> + )} + </> + )} + <p>{t`Next key download: ${nextDownload}`}</p> + </div> + </details> + </div> + ); +} + +function ServiceIdentity({ + service, + configLoaded, +}: { + service: PaymentService; + configLoaded: boolean; +}): VNode { + const { t } = useTranslation(); + return ( + <> + <div class="font-semibold text-gray-900"> + {serviceName(service.baseUrl)} + </div> + <div class="mt-1 break-all font-mono text-xs text-gray-500"> + {service.baseUrl} + </div> + {service.configuration ? ( + <details class="mt-2"> + <summary class="cursor-pointer text-xs font-semibold text-blue-700">{t`Technical identifier`}</summary> + <div + class="mt-1 break-all font-mono text-xs text-gray-500" + title={t`Identifies this payment service. Quote it if you are asked to.`} + > + {service.configuration.master_pub} + </div> + </details> + ) : ( + <p class="mt-2 text-xs text-amber-900"> + {configLoaded + ? t`Not listed in merchant configuration` + : t`Merchant configuration unavailable`} + </p> + )} + </> + ); +} + export function PaymentServicesScreen({ exchanges: propExchanges, isLoading = false, error, exchangesResource, + statuses: propStatuses, + statusResource, }: PaymentServicesScreenProps): VNode { const { t } = useTranslation(); const exchanges = exchangesResource?.data ?? propExchanges ?? []; + const statuses = statusResource?.data ?? propStatuses; + const configLoading = exchangesResource?.isLoading ?? isLoading; + const configLoaded = exchangesResource + ? exchangesResource.data !== undefined + : !configLoading && !error; + const statusLoading = statusResource?.isLoading === true; + const stale = Boolean(statusResource?.error && statuses !== undefined); + const services = mergePaymentServices(exchanges, statuses ?? []); return ( <div class="space-y-6"> @@ -64,7 +174,6 @@ export function PaymentServicesScreen({ title={t`Server payment services`} subtitle={t`Services configured by your provider to accept payments and make payouts.`} /> - {exchangesResource && ( <ReadErrorBanner resource={exchangesResource} @@ -72,107 +181,124 @@ export function PaymentServicesScreen({ /> )} {!exchangesResource && error && <div role="alert">{error}</div>} + {statusResource && ( + <ReadErrorBanner + resource={statusResource} + title={t`Could not load live service status`} + /> + )} + {stale && ( + <p + role="status" + class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900" + >{t`Live status could not be refreshed. These are the last known results.`}</p> + )} - <div class="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6"> + <div class="space-y-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"> <div> - <h2 class="font-bold text-gray-900 text-base mb-1">{t`Your payment services`}</h2> - <p class="text-xs text-gray-500"> - {t`A payment service takes the money from your customer and pays it into your bank account.`} - </p> - <p class="text-xs text-gray-600 mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3"> - {t`This page shows server configuration, not live service health. Check Bank accounts to see whether each service can pay into your account.`}{" "} + <h2 class="mb-1 text-base font-bold text-gray-900">{t`Your payment services`}</h2> + <p class="text-xs text-gray-500">{t`A payment service takes the money from your customer and pays it into your bank account.`}</p> + <p class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-gray-600"> + {t`Status reflects your server’s latest attempt to download each service’s payment keys. Check Bank accounts to see whether each service can pay into your account.`}{" "} <a href="#/money/payout-accounts" class="font-semibold text-blue-700 hover:underline" >{t`Check bank accounts`}</a> </p> </div> - - {exchangesResource?.data === undefined && - exchangesResource?.error ? null : exchanges.length === 0 && - !isLoading ? ( - <div class="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-900 text-xs font-semibold"> - ⚠️ <strong>{t`No payment services are configured.`}</strong>{" "} + {configLoaded && exchanges.length === 0 && ( + <div + role="status" + class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs font-semibold text-amber-900" + > + <strong>{t`No payment services are configured.`}</strong>{" "} {t`Without one, this server cannot take any payments. Contact your provider.`} </div> - ) : ( + )} + {statuses?.length === 0 && !statusResource?.error && ( + <p + role="status" + class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-900" + >{t`The server returned no live service status entries.`}</p> + )} + {configLoading && ( + <p class="text-sm text-gray-500">{t`Loading payment service details...`}</p> + )} + {statusLoading && services.length === 0 && ( + <p class="text-sm text-gray-500">{t`Loading status…`}</p> + )} + {services.length > 0 && ( <> <div class="space-y-3 md:hidden"> - {isLoading ? ( - <div class="rounded-xl border border-gray-200 p-6 text-center text-sm text-gray-500">{t`Loading payment service details...`}</div> - ) : ( - exchanges.map((ex) => ( - <article - key={ex.base_url} - class="rounded-xl border border-gray-200 bg-white p-4 shadow-xs" - > - <h3 class="font-semibold text-gray-900"> - {serviceName(ex.base_url)} - </h3> - <p class="mt-1 break-all font-mono text-xs text-gray-500"> - {ex.base_url} - </p> - <dl class="mt-3 text-xs"> + {services.map((service) => ( + <article + key={service.baseUrl} + class="rounded-xl border border-gray-200 bg-white p-4 shadow-xs" + > + <ServiceIdentity + service={service} + configLoaded={configLoaded} + /> + <dl class="mt-3 space-y-2 text-xs"> + <div> <dt class="text-gray-500">{t`Currency`}</dt> - <dd class="font-semibold text-gray-900">{ex.currency}</dd> - </dl> - <details class="mt-3"> - <summary class="cursor-pointer text-xs font-semibold text-blue-700">{t`Technical identifier`}</summary> - <div class="mt-1 break-all font-mono text-2xs text-gray-500"> - {ex.master_pub} - </div> - </details> - </article> - )) - )} + <dd class="font-semibold text-gray-900"> + {service.configuration?.currency ?? "—"} + </dd> + </div> + <div> + <dt class="mb-1 text-gray-500">{t`Status`}</dt> + <dd> + <ServiceStatus + status={service.status} + loading={statusLoading} + stale={stale} + /> + </dd> + </div> + </dl> + </article> + ))} </div> - <div class="hidden bg-white shadow rounded-lg border border-gray-200 md:block"> + <div class="hidden rounded-lg border border-gray-200 md:block"> <table class="min-w-full divide-y divide-gray-200 text-sm"> - <thead class="bg-gray-50 text-gray-500 font-semibold"> + <thead class="bg-gray-50 font-semibold text-gray-500"> <tr> - <th class="px-4 py-3 text-left">{t`Payment service`}</th> - <th class="px-4 py-3 text-left">{t`Currency`}</th> + <th + scope="col" + class="px-4 py-3 text-left" + >{t`Payment service`}</th> + <th + scope="col" + class="px-4 py-3 text-left" + >{t`Currency`}</th> + <th scope="col" class="px-4 py-3 text-left">{t`Status`}</th> </tr> </thead> <tbody class="divide-y divide-gray-200"> - {isLoading ? ( - <tr> - <td - colSpan={2} - class="px-4 py-8 text-center text-gray-500" - > - {t`Loading payment service details...`} + {services.map((service) => ( + <tr + key={service.baseUrl} + class="transition-colors hover:bg-gray-50" + > + <td class="px-4 py-3 align-top"> + <ServiceIdentity + service={service} + configLoaded={configLoaded} + /> + </td> + <td class="px-4 py-3 align-top font-semibold text-gray-800"> + {service.configuration?.currency ?? "—"} + </td> + <td class="px-4 py-3 align-top"> + <ServiceStatus + status={service.status} + loading={statusLoading} + stale={stale} + /> </td> </tr> - ) : ( - exchanges.map((ex) => ( - <tr - key={ex.base_url} - class="hover:bg-gray-50 transition-colors" - > - <td class="px-4 py-3 text-gray-900"> - <div class="font-semibold"> - {serviceName(ex.base_url)} - </div> - <div class="text-xs text-gray-500 font-mono break-all mt-0.5"> - {ex.base_url} - </div> - <details class="mt-1"> - <summary class="text-2xs text-blue-700 font-semibold cursor-pointer">{t`Technical identifier`}</summary> - <div - class="mt-1 text-2xs text-gray-500 font-mono break-all" - title={t`Identifies this payment service. Quote it if you are asked to.`} - > - {ex.master_pub} - </div> - </details> - </td> - <td class="px-4 py-3 font-semibold text-gray-800"> - {ex.currency} - </td> - </tr> - )) - )} + ))} </tbody> </table> </div> diff --git a/packages/taler-merchant-webui/src/screens/paymentServices.test.tsx b/packages/taler-merchant-webui/src/screens/paymentServices.test.tsx @@ -0,0 +1,266 @@ +/* + 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. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { render } from "preact"; +import { + PaymentServicesScreen, + type PaymentServicesScreenProps, +} from "./PaymentServicesScreen.js"; +import { + exchangeHealth, + exchangeKeysExpired, + mergePaymentServices, +} from "./paymentServices.js"; +import type { ExchangeStatus } from "../api/exchangeStatus.js"; +import type { RemoteResource } from "../api/contracts.js"; +import { normalizeApiFailure } from "../api/failure.js"; + +const configuration = { + base_url: "https://exchange.example/base", + currency: "CHF", + master_pub: "KEY", +}; +const status: ExchangeStatus = { + exchange_url: "https://exchange.example/base/", + keys_http_status: 200, + keys_ec: 0, + keys_hint: "Success", + keys_expiration: { t_s: "never" }, + next_download: { t_s: "never" }, +}; +function resource<T>(data: T | undefined, failed = false): RemoteResource<T> { + return { + data, + error: failed ? normalizeApiFailure(new Error("offline")) : undefined, + isLoading: false, + isRefreshing: false, + refresh: async () => {}, + }; +} +function screen( + props: PaymentServicesScreenProps, + check: (container: HTMLElement) => void, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + try { + render(<PaymentServicesScreen {...props} />, container); + check(container); + } finally { + render(null, container); + container.remove(); + } +} + +test("merge matches canonical URLs, retains metadata and includes entries from either endpoint", () => { + const missing = { ...configuration, base_url: "https://missing.example/" }; + const additional = { ...status, exchange_url: "https://additional.example/" }; + const services = mergePaymentServices( + [configuration, missing], + [additional, status], + ); + assert.equal(services.length, 3); + assert.equal(services[0]!.configuration, configuration); + assert.equal(services[0]!.status, status); + assert.equal(services[1]!.status, undefined); + assert.equal(services[2]!.configuration, undefined); + assert.equal(services[2]!.status, additional); +}); + +test("merge does not conflate different schemes or exchange paths", () => { + const variants = [ + "http://exchange.example/base/", + "https://exchange.example/other/", + "https://exchange.example/Base/", + ]; + assert.equal( + mergePaymentServices( + [configuration], + variants.map((exchange_url) => ({ ...status, exchange_url })), + ).length, + 4, + ); + assert.equal( + mergePaymentServices([{ ...configuration, base_url: "https://[bad" }], []) + .length, + 1, + ); +}); + +test("health reflects both HTTP and processing result; expiration is a separate warning", () => { + assert.equal(exchangeHealth(status), "up"); + for (const failure of [ + { keys_http_status: 0 }, + { keys_http_status: 502 }, + { keys_ec: 2010 }, + ]) { + assert.equal(exchangeHealth({ ...status, ...failure }), "down"); + } + assert.equal(exchangeHealth(undefined), "unavailable"); + assert.equal(exchangeKeysExpired(status), false); + assert.equal( + exchangeKeysExpired({ ...status, keys_expiration: undefined }), + false, + ); + const expired = { ...status, keys_expiration: { t_s: 1000 } }; + assert.equal(exchangeKeysExpired(expired, 1_000_000), true); + assert.equal(exchangeKeysExpired(expired, 999_999), false); + assert.equal(exchangeHealth(expired), "up"); +}); + +test("desktop and mobile show status details, expired keys, and configuration-only entries", () => { + screen( + { + exchanges: [ + configuration, + { ...configuration, base_url: "https://missing.example/" }, + ], + statuses: [{ ...status, keys_expiration: { t_s: 1 } }], + }, + (container) => { + for (const view of [ + container.querySelector("table")!, + container.querySelector("article")!, + ]) { + assert.match(view.textContent!, /UP/); + assert.match(view.textContent!, /keys have expired/); + assert.match(view.textContent!, /Ready until:/); + assert.match(view.textContent!, /Next key download: Not scheduled/); + assert.ok( + [...view.querySelectorAll("details > summary")].some( + (x) => x.textContent === "Status details", + ), + ); + } + assert.match(container.textContent!, /Status unavailable/); + assert.match(container.textContent!, /CHF/); + }, + ); +}); + +test("DOWN details include HTTP zero, processing errors, and safely rendered hints", () => { + for (const httpStatus of [0, 200, 502]) { + screen( + { + exchanges: [configuration], + statuses: [ + { + ...status, + keys_http_status: httpStatus, + keys_ec: 2010, + keys_hint: "<script>bad</script>", + }, + ], + }, + (container) => { + assert.match(container.textContent!, /DOWN/); + assert.match( + container.textContent!, + new RegExp(`Last HTTP status was ${httpStatus}:`), + ); + assert.match(container.textContent!, /#2010/); + assert.equal(container.querySelector("script"), null); + if (httpStatus === 0) + assert.match(container.textContent!, /No HTTP response/); + }, + ); + } +}); + +test("missing and never expiration are explicit", () => { + screen({ exchanges: [configuration], statuses: [status] }, (c) => + assert.match(c.textContent!, /Ready until: Never/), + ); + screen( + { + exchanges: [configuration], + statuses: [{ ...status, keys_expiration: undefined }], + }, + (c) => assert.match(c.textContent!, /Key expiration is unavailable/), + ); +}); + +test("status-only rows survive empty or failed configuration without invented metadata", () => { + screen({ exchangesResource: resource([]), statuses: [status] }, (c) => { + assert.match(c.textContent!, /No payment services are configured/); + assert.match(c.textContent!, /Not listed in merchant configuration/); + assert.match(c.textContent!, /UP/); + assert.doesNotMatch(c.textContent!, /Technical identifier/); + }); + screen( + { + exchangesResource: resource<(typeof configuration)[]>(undefined, true), + statuses: [status], + }, + (c) => { + assert.match(c.textContent!, /Could not load payment services/); + assert.match(c.textContent!, /Merchant configuration unavailable/); + assert.match(c.textContent!, /UP/); + assert.doesNotMatch(c.textContent!, /No payment services are configured/); + }, + ); +}); + +test("status failures preserve configuration and distinguish stale data from unavailable data", () => { + screen( + { + exchanges: [configuration], + statusResource: resource<ExchangeStatus[]>(undefined, true), + }, + (c) => { + assert.match(c.textContent!, /CHF/); + assert.match(c.textContent!, /Could not load live service status/); + assert.match(c.textContent!, /Status unavailable/); + assert.doesNotMatch( + c.textContent!, + /DOWN|no live service status entries/, + ); + }, + ); + screen( + { exchanges: [configuration], statusResource: resource([status], true) }, + (c) => { + assert.match(c.textContent!, /last known results/); + assert.match(c.textContent!, /Last known status/); + assert.match(c.textContent!, /UP/); + }, + ); + screen( + { exchanges: [configuration], statusResource: resource([status]) }, + (c) => { + assert.doesNotMatch( + c.textContent!, + /last known|Last known|Could not load/, + ); + assert.match(c.textContent!, /UP/); + }, + ); +}); + +test("successful empty status and pending requests are not conflated with errors", () => { + screen({ exchanges: [configuration], statuses: [] }, (c) => + assert.match(c.textContent!, /no live service status entries/), + ); + screen( + { + exchangesResource: { ...resource(undefined), isLoading: true }, + statusResource: { ...resource(undefined), isLoading: true }, + }, + (c) => { + assert.match(c.textContent!, /Loading payment service details/); + assert.match(c.textContent!, /Loading status/); + assert.doesNotMatch( + c.textContent!, + /No payment services are configured|no live service status entries|Could not load/, + ); + }, + ); +}); diff --git a/packages/taler-merchant-webui/src/screens/paymentServices.ts b/packages/taler-merchant-webui/src/screens/paymentServices.ts @@ -0,0 +1,68 @@ +/* + 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. + */ + +import { canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import type { ExchangeStatus } from "../api/exchangeStatus.js"; + +export interface ExchangeServiceItem { + base_url: string; + currency: string; + master_pub: string; +} + +export interface PaymentService { + baseUrl: string; + configuration?: ExchangeServiceItem; + status?: ExchangeStatus; +} + +function exchangeKey(url: string): string { + try { + return canonicalizeBaseUrl(url); + } catch { + // Keep malformed configuration visible without breaking the entire screen. + return url; + } +} + +export function mergePaymentServices( + configuration: ExchangeServiceItem[], + statuses: ExchangeStatus[], +): PaymentService[] { + const services = new Map<string, PaymentService>(); + for (const item of configuration) { + services.set(exchangeKey(item.base_url), { + baseUrl: item.base_url, + configuration: item, + }); + } + for (const status of statuses) { + const key = exchangeKey(status.exchange_url); + const service = services.get(key) ?? { baseUrl: status.exchange_url }; + services.set(key, { ...service, status }); + } + return [...services.values()]; +} + +export function exchangeHealth( + status: ExchangeStatus | undefined, +): "up" | "down" | "unavailable" { + if (!status) return "unavailable"; + return status.keys_http_status === 200 && status.keys_ec === 0 + ? "up" + : "down"; +} + +export function exchangeKeysExpired( + status: ExchangeStatus, + now = Date.now(), +): boolean { + const expiration = status.keys_expiration?.t_s; + return typeof expiration === "number" && expiration * 1000 <= now; +} diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx @@ -736,9 +736,27 @@ export const STORIES: Story[] = [ id: "integration-paymentservices", category: "Integration & Advanced", name: "Payment Services", - description: "The payment services this server accepts money through.", + description: + "Configured payment services with successful and failed key downloads.", render: () => ( <PaymentServicesScreen + statuses={[ + { + exchange_url: "https://payments.example.ch/", + keys_http_status: 200, + keys_ec: 0, + keys_hint: "", + keys_expiration: { t_s: "never" }, + next_download: { t_s: "never" }, + }, + { + exchange_url: "https://payments.example.org/", + keys_http_status: 502, + keys_ec: 2010, + keys_hint: "The service did not provide a valid response.", + next_download: { t_s: "never" }, + }, + ]} exchanges={[ { base_url: "https://payments.example.ch/", diff --git a/packages/taler-merchant-webui/src/tutorial/tutorialData.tsx b/packages/taler-merchant-webui/src/tutorial/tutorialData.tsx @@ -2083,7 +2083,7 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] { menuEntry: "Server payment services", keyTakeaways: [ t`Payment services are set up by whoever runs your server, not by you.`, - t`The screen lists the ones this server accepts, and the currency each is trusted for.`, + t`The screen combines configured services and their latest key-download status. An UP service can still have expired keys; check any warnings.`, t`There is nothing here to configure. If one is not working, the people who provide it are the ones to tell.`, ], sections: [ @@ -2096,7 +2096,7 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] { }, { kind: "p", - text: t`Nothing here can be changed from this screen — the list is whatever your provider has set the server up with. Whether *your* account with a service is ready to be paid into is a different question, and **Bank accounts & payouts** is where you answer it. If a service is failing, your provider is the one to tell.`, + text: t`UP means the server’s latest key download succeeded. DOWN means it failed; open Status details for the HTTP status and error. Missing status is shown as unavailable. This screen refreshes status automatically while open. Whether your account can receive payouts is shown under **Bank accounts & payouts**. Contact your provider if a problem persists.`, }, { kind: "p", @@ -2109,6 +2109,27 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] { ], renderPreview: (ds) => ( <PaymentServicesScreen + statuses={ + ds === "empty" + ? [] + : [ + { + exchange_url: tutorialExchangeUrl.value, + keys_http_status: 200, + keys_ec: 0, + keys_hint: "", + keys_expiration: { t_s: "never" }, + next_download: { t_s: "never" }, + }, + { + exchange_url: "https://payments.example.ch/", + keys_http_status: 502, + keys_ec: 2010, + keys_hint: t`The service did not provide a valid response.`, + next_download: { t_s: "never" }, + }, + ] + } exchanges={ ds === "empty" ? []