taler-typescript-core

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

commit abf37bc226b7ccc07b2619599bb9eb85804af033
parent 107f89219b113ee55371d7b5c9388d837c51a95e
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 00:32:28 +0200

wallet-cli: pretty-print balances

Diffstat:
Apackages/taler-wallet-cli/src/balance-pretty.test.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/balance-pretty.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 39+++++++++++++++++++++++++++++----------
3 files changed, 211 insertions(+), 10 deletions(-)

diff --git a/packages/taler-wallet-cli/src/balance-pretty.test.ts b/packages/taler-wallet-cli/src/balance-pretty.test.ts @@ -0,0 +1,89 @@ +/* + 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 { BalanceFlag, ScopeType, WalletBalance } from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { formatPrettyBalance } from "./balance-pretty.js"; + +function makeBalance(extra: Record<string, unknown> = {}): WalletBalance { + return { + scopeInfo: { type: ScopeType.Global, currency: "KUDOS" }, + available: "KUDOS:12.50", + pendingIncoming: "KUDOS:0", + pendingOutgoing: "KUDOS:0", + flags: [], + ...extra, + } as WalletBalance; +} + +test("pretty balance output focuses on funds that need attention", () => { + const lines = formatPrettyBalance( + makeBalance({ + pendingIncoming: "KUDOS:2", + pendingOutgoing: "KUDOS:1", + }), + ); + + assert.deepStrictEqual(lines, [ + "KUDOS KUDOS:12.50 [available]", + " Pending incoming: KUDOS:2", + " Pending outgoing: KUDOS:1", + ]); +}); + +test("pretty balance output identifies non-global scopes and restrictions", () => { + const lines = formatPrettyBalance( + makeBalance({ + scopeInfo: { + type: ScopeType.Exchange, + currency: "KUDOS", + url: "https://exchange.example/", + }, + flags: [BalanceFlag.OutgoingKyc], + }), + ); + + assert.ok(lines.includes(" Exchange: https://exchange.example/")); + assert.ok(lines.includes(" Restrictions: outgoing-kyc")); +}); + +test("the one-line view remains compact", () => { + const lines = formatPrettyBalance( + makeBalance({ pendingIncoming: "KUDOS:2" }), + false, + true, + ); + + assert.deepStrictEqual(lines, [ + "KUDOS KUDOS:12.50 [available] — Pending incoming: KUDOS:2", + ]); +}); + +test("verbose output includes optional diagnostic details", () => { + const lines = formatPrettyBalance( + makeBalance({ + shoppingUrls: ["https://shop.example/"], + disablePeerPayments: true, + disableDirectDeposits: true, + }), + true, + ); + + assert.ok(lines.includes(" Shopping URLs: https://shop.example/")); + assert.ok(lines.includes(" Peer payments: disabled")); + assert.ok(lines.includes(" Direct deposits: disabled")); +}); diff --git a/packages/taler-wallet-cli/src/balance-pretty.ts b/packages/taler-wallet-cli/src/balance-pretty.ts @@ -0,0 +1,93 @@ +/* + 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/>. + */ + +/** + * @file + * Compact, useful rendering of wallet balances. + */ + +import { + Amounts, + ScopeInfo, + ScopeType, + WalletBalance, +} from "@gnu-taler/taler-util"; + +function scopeDescription(scopeInfo: ScopeInfo): string | undefined { + switch (scopeInfo.type) { + case ScopeType.Global: + return undefined; + case ScopeType.Exchange: + return `Exchange: ${scopeInfo.url}`; + case ScopeType.Auditor: + return `Auditor: ${scopeInfo.url}`; + case ScopeType.ExchangeLegacyKeys: + return `Exchange (old keys): ${scopeInfo.url}`; + } +} + +function scopeDetails(scopeInfo: ScopeInfo): string | undefined { + if (scopeInfo.type === ScopeType.ExchangeLegacyKeys) { + return `Exchange master public key: ${scopeInfo.masterPub}`; + } + return undefined; +} + +/** Render one balance for a person reading the CLI output. */ +export function formatPrettyBalance( + balance: WalletBalance, + verbose = false, + oneline = false, +): string[] { + const lines = [ + `${balance.scopeInfo.currency} ${balance.available} [available]`, + ]; + const scope = scopeDescription(balance.scopeInfo); + const pendingIncoming = Amounts.isNonZero(balance.pendingIncoming) + ? `Pending incoming: ${balance.pendingIncoming}` + : undefined; + const pendingOutgoing = Amounts.isNonZero(balance.pendingOutgoing) + ? `Pending outgoing: ${balance.pendingOutgoing}` + : undefined; + + if (oneline) { + const parts = [...lines]; + if (pendingIncoming != null) parts.push(pendingIncoming); + if (pendingOutgoing != null) parts.push(pendingOutgoing); + if (scope != null) parts.push(scope); + if (balance.flags.length > 0) + parts.push(`Restrictions: ${balance.flags.join(", ")}`); + return [parts.join(" — ")]; + } + + if (pendingIncoming != null) lines.push(` ${pendingIncoming}`); + if (pendingOutgoing != null) lines.push(` ${pendingOutgoing}`); + if (scope != null) lines.push(` ${scope}`); + if (balance.flags.length > 0) { + lines.push(` Restrictions: ${balance.flags.join(", ")}`); + } + + if (!verbose) return lines; + + const detail = scopeDetails(balance.scopeInfo); + if (detail != null) lines.push(` ${detail}`); + if (balance.shoppingUrls != null && balance.shoppingUrls.length > 0) { + lines.push(` Shopping URLs: ${balance.shoppingUrls.join(", ")}`); + } + if (balance.disablePeerPayments) lines.push(" Peer payments: disabled"); + if (balance.disableDirectDeposits) lines.push(" Direct deposits: disabled"); + return lines; +} diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -104,6 +104,7 @@ import { TX_STATE_SPEC_SYNTAX, } from "./waitspec.js"; import { formatPrettyTransaction } from "./transactions-pretty.js"; +import { formatPrettyBalance } from "./balance-pretty.js"; import * as fs from "node:fs"; @@ -740,10 +741,19 @@ async function withWallet<T>( walletCli .subcommand("balance", "balance", { help: "Show wallet balance." }) .flag("json", ["--json"], { - help: "Show raw JSON (the default).", + help: "Print JSON, even when stdout is a terminal.", + }) + .flag("pretty", ["--pretty"], { + help: "Print a human-readable balance, even when stdout is not a terminal.", }) .flag("human", ["--human"], { - help: "Show one line per currency instead of JSON.", + help: "Deprecated alias for --pretty.", + }) + .flag("verbose", ["-v", "--verbose"], { + help: "Include scope and capability details in pretty output.", + }) + .flag("oneline", ["--oneline"], { + help: "Print one compact line per balance in pretty output.", }) .action(async (args) => { await withWallet( @@ -756,18 +766,27 @@ walletCli WalletApiOperation.GetBalances, {}, ); - // JSON stays the default: scripts rely on it. - if (!args.balance.human || args.balance.json) { + // A terminal gets a useful overview, while pipes retain the stable + // machine-readable form. Either flag explicitly selects its format; + // --json wins if a caller accidentally passes both. + const pretty = + !args.balance.json && + (args.balance.pretty || + args.balance.human || + process.stdout.isTTY === true); + if (!pretty) { console.log(JSON.stringify(balance, undefined, 2)); return; } - for (const bal of balance.balances) { - console.log( - `${bal.scopeInfo.currency}: available=${bal.available} ` + - `pending-incoming=${bal.pendingIncoming} ` + - `pending-outgoing=${bal.pendingOutgoing}`, - ); + if (balance.balances.length === 0) { + console.log("No balances."); + return; } + const verbose = args.balance.verbose || args.wallet.verbose; + const rendered = balance.balances.map((bal) => + formatPrettyBalance(bal, verbose, args.balance.oneline), + ); + console.log(rendered.map((lines) => lines.join("\n")).join("\n\n")); }, ); });