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:
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"));
},
);
});