taler-typescript-core

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

commit 9219b1a3ded54f66020650731b577266850e582f
parent 9439f39b0138a89839b4eb22828cc8a965d66c42
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 13:45:11 +0200

wallet web UI: display settled transaction costs

Use final amounts in transaction history and balance charts. Keep the
confirmed quote in details, show when settlement evidence is unavailable,
and exclude abort refund credits already included in the payment cost.

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

Diffstat:
Mpackages/wallet-webui/src/api/balance-history.ts | 2+-
Mpackages/wallet-webui/src/routes/App.tsx | 12++++++------
Mpackages/wallet-webui/src/routes/balance-model.ts | 13++++++-------
Mpackages/wallet-webui/src/routes/transaction-model.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/wallet-webui/src/screens/TransactionDetailScreen.tsx | 1+
Mpackages/wallet-webui/src/screens/TransactionScreen.tsx | 1+
Mpackages/wallet-webui/src/testing/demo-wallet.ts | 15+++++++++++++++
Mpackages/wallet-webui/test/balance-history.test.ts | 2+-
Mpackages/wallet-webui/test/balance-model.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/transaction-model.test.ts | 33++++++++++++++++++++++++++++++++-
10 files changed, 156 insertions(+), 27 deletions(-)

diff --git a/packages/wallet-webui/src/api/balance-history.ts b/packages/wallet-webui/src/api/balance-history.ts @@ -35,7 +35,7 @@ export function balanceHistoryPageRequest( if (!enabled || !scopeInfo) return undefined; const request: GetTransactionsV2Request = { limit: -PAGE_SIZE, - filterByState: "done", + filterByState: "final", includeRefreshes: true, scopeInfo, }; diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -796,9 +796,9 @@ function BalanceRoute() { ? i18n.str`Estimated history: a transaction involving more than one exchange is counted in full for each exchange, and deleted activity is unavailable.` : i18n.str`Estimated history reconstructed from retained completed transactions; deleted activity is unavailable.` } - recentTransactions={recent.data?.transactions.map((transaction) => - transactionHistoryView(transaction, language), - )} + recentTransactions={recent.data?.transactions + .filter((t) => t.type !== TransactionType.Refund || !t.isAbortRecovery) + .map((transaction) => transactionHistoryView(transaction, language))} pendingTransactions={pending.data?.transactions.map((transaction) => transactionHistoryView(transaction, language), )} @@ -4755,9 +4755,9 @@ function TransactionsRoute() { WalletApiOperation.GetTransactionsV2, { limit: -100, ...(supportedScope ? { scopeInfo: supportedScope } : {}) }, ); - const transactions = query.data?.transactions.map((transaction) => - transactionHistoryView(transaction, language), - ); + const transactions = query.data?.transactions + .filter((t) => t.type !== TransactionType.Refund || !t.isAbortRecovery) + .map((transaction) => transactionHistoryView(transaction, language)); return ( <TransactionScreen transactions={transactions} diff --git a/packages/wallet-webui/src/routes/balance-model.ts b/packages/wallet-webui/src/routes/balance-model.ts @@ -8,7 +8,6 @@ import { Amounts, BalanceFlag, ScopeType, - TransactionMajorState, TransactionType, } from "@gnu-taler/taler-util"; import type { BalanceView } from "../screens/BalanceScreen.js"; @@ -145,8 +144,7 @@ function balanceDirection(transaction: Transaction): "credit" | "debit" { * Deleted transactions cannot be inferred. For an exchange scope, * wallet-core currently reports whether an exchange participated but not the * delta attributable to that exchange, so a multi-exchange transaction's full - * effective amount is applied to every participating exchange. Pending and - * failed transactions do not become history points. + * effective amount is applied to every participating exchange. Transactions without a known final amount do not become history points. */ export function balanceTrendView( balance: BalanceView | undefined, @@ -174,12 +172,13 @@ export function balanceTrendView( }> = []; for (const transaction of transactions) { if ( - transaction.txState.major !== TransactionMajorState.Done || - !("amountEffective" in transaction) || - typeof transaction.amountEffective !== "string" + transaction.type === TransactionType.Refund && + transaction.isAbortRecovery ) continue; - const effective = Amounts.parse(transaction.amountEffective); + const finalAmount = transaction.amountEffectiveFinal; + if (finalAmount === undefined) continue; + const effective = Amounts.parse(finalAmount); if ( !effective || effective.currency !== current.currency || diff --git a/packages/wallet-webui/src/routes/transaction-model.ts b/packages/wallet-webui/src/routes/transaction-model.ts @@ -34,6 +34,7 @@ export interface TransactionHistoryView { typeLabel: string; icon: string; amount: string; + amountUnavailable?: boolean; direction: TransactionDirection; state: string; tone: TransactionTone; @@ -377,7 +378,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Purchase price`, value: transaction.amountRaw }, { - label: i18n.str`Total wallet debit`, + label: i18n.str`Confirmed wallet debit`, value: transaction.amountEffective, }, ); @@ -403,7 +404,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Refund amount`, value: transaction.amountRaw }, { - label: i18n.str`Added to wallet`, + label: i18n.str`Expected wallet credit`, value: transaction.amountEffective, }, ); @@ -421,7 +422,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { value: transaction.amountRaw, }, { - label: i18n.str`Added to wallet`, + label: i18n.str`Expected wallet credit`, value: transaction.amountEffective, }, ); @@ -446,7 +447,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { value: transaction.amountRaw, }, { - label: i18n.str`Total wallet debit`, + label: i18n.str`Confirmed wallet debit`, value: transaction.amountEffective, }, ); @@ -496,7 +497,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Invoice amount`, value: transaction.amountRaw }, { - label: i18n.str`Added to wallet`, + label: i18n.str`Expected wallet credit`, value: transaction.amountEffective, }, ); @@ -516,7 +517,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Transfer amount`, value: transaction.amountRaw }, { - label: i18n.str`Total wallet debit`, + label: i18n.str`Confirmed wallet debit`, value: transaction.amountEffective, }, ); @@ -535,7 +536,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Transfer amount`, value: transaction.amountRaw }, { - label: i18n.str`Added to wallet`, + label: i18n.str`Expected wallet credit`, value: transaction.amountEffective, }, ); @@ -569,7 +570,7 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { fields.push( { label: i18n.str`Recovered amount`, value: transaction.amountRaw }, { - label: i18n.str`Added to wallet`, + label: i18n.str`Expected wallet credit`, value: transaction.amountEffective, }, ); @@ -628,13 +629,28 @@ export function transactionHistoryView( const presentation = typePresentations()[transaction.type]; const names = titleAndSubtitle(transaction); const state = transactionStateView(transaction); + const finalState = [ + TransactionMajorState.Done, + TransactionMajorState.Aborted, + TransactionMajorState.Failed, + TransactionMajorState.Expired, + ].includes(transaction.txState.major); + const amountUnavailable = + finalState && transaction.amountEffectiveFinal === undefined; return { id: transaction.transactionId, ...names, typeLabel: presentation.label, icon: presentation.icon, - amount: transaction.amountEffective, - direction: presentation.direction, + amount: + transaction.amountEffectiveFinal ?? + (amountUnavailable ? "—" : transaction.amountEffective), + amountUnavailable, + direction: + transaction.type === TransactionType.Refresh && + transaction.amountEffectiveFinal !== undefined + ? "debit" + : presentation.direction, state: state.label, tone: state.tone, ...timestampView(transaction, language), @@ -817,7 +833,27 @@ export function transactionDetailView( ? { localId: transaction.localTransactionId } : {}), statusDetail: state.detail, - fields: fieldsFor(transaction), + fields: [ + ...fieldsFor(transaction), + ...(transaction.amountEffectiveFinal !== undefined + ? [ + { + label: + base.direction === "credit" + ? i18n.str`Final amount received` + : i18n.str`Final cost`, + value: transaction.amountEffectiveFinal, + }, + ] + : base.amountUnavailable + ? [ + { + label: i18n.str`Final amount`, + value: i18n.str`Unavailable until settlement can be established`, + }, + ] + : []), + ], ...(technicalFields.length ? { technicalFields } : {}), actions, ...(error ? { error } : {}), diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx @@ -36,6 +36,7 @@ export type TransactionPaymentReview = }; function amountLabel(transaction: TransactionDetailView): string { + if (transaction.amountUnavailable) return transaction.amount; if (transaction.direction === "credit") return `+${transaction.amount}`; if (transaction.direction === "debit") return `−${transaction.amount}`; return transaction.amount; diff --git a/packages/wallet-webui/src/screens/TransactionScreen.tsx b/packages/wallet-webui/src/screens/TransactionScreen.tsx @@ -6,6 +6,7 @@ import type { TransactionHistoryView } from "../routes/transaction-model.js"; import { i18n } from "../i18n/runtime.js"; function amountLabel(transaction: TransactionHistoryView): string { + if (transaction.amountUnavailable) return transaction.amount; if (transaction.direction === "credit") return `+${transaction.amount}`; if (transaction.direction === "debit") return `−${transaction.amount}`; return transaction.amount; diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts @@ -149,6 +149,8 @@ function commonTransaction( : [TransactionAction.Delete], amountRaw, amountEffective, + amountEffectiveFinal: + state === TransactionMajorState.Done ? amountEffective : undefined, }; } @@ -454,6 +456,19 @@ export class DemoWalletConnection implements WalletConnection { transaction: Transaction, oldMajor = TransactionMajorState.None, ): void { + if (transaction.txState.major === TransactionMajorState.Done) { + transaction.amountEffectiveFinal = transaction.amountEffective; + } else if ( + [ + TransactionMajorState.Aborted, + TransactionMajorState.Expired, + TransactionMajorState.Failed, + ].includes(transaction.txState.major) + ) { + transaction.amountEffectiveFinal = amount("CHF", 0); + } else { + delete transaction.amountEffectiveFinal; + } this.emit({ type: NotificationType.TransactionStateTransition, transactionId: transaction.transactionId, diff --git a/packages/wallet-webui/test/balance-history.test.ts b/packages/wallet-webui/test/balance-history.test.ts @@ -39,7 +39,7 @@ test("balance history pages completed transactions with refresh fees", () => { const scopeInfo = { type: ScopeType.Global, currency: "CHF" } as const; const first = balanceHistoryPageRequest(scopeInfo, true); assert.equal(first?.limit, -128); - assert.equal(first?.filterByState, "done"); + assert.equal(first?.filterByState, "final"); assert.equal(first?.includeRefreshes, true); assert.deepEqual(first?.scopeInfo, scopeInfo); diff --git a/packages/wallet-webui/test/balance-model.test.ts b/packages/wallet-webui/test/balance-model.test.ts @@ -187,6 +187,8 @@ test("balance trend walks completed scoped transaction effects backwards", () => txActions: [], amountRaw: amountEffective, amountEffective, + amountEffectiveFinal: + major === TransactionMajorState.Done ? amountEffective : undefined, ...(type === TransactionType.Withdrawal ? { exchangeBaseUrl: "https://exchange.example/", @@ -291,6 +293,7 @@ test("balance trend includes standalone refresh fees and empty hourly buckets", txActions: [], amountRaw: "CHF:0", amountEffective: "CHF:1", + amountEffectiveFinal: "CHF:1", } as unknown as Transaction; const points = balanceTrendView(balance, [refresh], "day", anchor); @@ -303,3 +306,46 @@ test("balance trend includes standalone refresh fees and empty hourly buckets", assert.equal(points.at(-1)?.value, 9); assert.equal(points.at(-1)?.transactionCount, 1); }); + +test("balance history counts abort losses once and keeps ordinary refunds separate", () => { + const balance = balancesToView({ + balances: [ + { + scopeInfo: { type: ScopeType.Global, currency: "CHF" }, + available: "CHF:10", + pendingIncoming: "CHF:0", + pendingOutgoing: "CHF:0", + flags: [], + }, + ], + haveProdBalance: true, + }).balances?.[0]; + const anchor = 1_800_000_000; + const transaction = (type: TransactionType, amount: string, extra = {}) => + ({ + transactionId: `txn:${type}:test`, + type, + timestamp: { t_s: anchor - 60 }, + txState: { major: TransactionMajorState.Aborted }, + amountEffective: "CHF:20", + amountEffectiveFinal: amount, + ...extra, + }) as unknown as Transaction; + const points = balanceTrendView( + balance, + [ + transaction(TransactionType.Payment, "CHF:0.3"), + transaction(TransactionType.Refund, "CHF:19.7", { + isAbortRecovery: true, + }), + transaction(TransactionType.Refund, "CHF:1", { + txState: { major: TransactionMajorState.Done }, + }), + transaction(TransactionType.Refresh, "CHF:0"), + ], + "day", + anchor, + ); + assert.equal(points[0].value, 9.3); + assert.equal(points.at(-1)?.value, 10); +}); diff --git a/packages/wallet-webui/test/transaction-model.test.ts b/packages/wallet-webui/test/transaction-model.test.ts @@ -317,7 +317,7 @@ test("contract-less payment failures omit unknown price fields", () => { !view.fields.some( (field) => field.label === "Purchase price" || - field.label === "Total wallet debit", + field.label === "Confirmed wallet debit", ), ); }); @@ -592,3 +592,34 @@ test("deposit KYC authentication exposes the source account and transfer continu ); assert.equal(laterKyc.continuationAction, undefined); }); + +test("final cost replaces the headline while preserving the confirmed amount in details", () => { + const tx = payment({ + txState: { major: TransactionMajorState.Aborted }, + amountEffectiveFinal: "CHF:0.30", + }); + const history = transactionHistoryView(tx); + assert.equal(history.amount, "CHF:0.30"); + assert.equal(history.direction, "debit"); + const details = transactionDetailView(tx); + assert.ok(details.fields.some((f) => f.value === "CHF:5.10")); + assert.ok(details.fields.some((f) => f.value === "CHF:0.30")); +}); + +test("unsettled terminal transactions do not present their quote as the actual cost", () => { + const history = transactionHistoryView( + payment({ txState: { major: TransactionMajorState.Aborted } }), + ); + assert.equal(history.amount, "—"); + assert.equal(history.amountUnavailable, true); + assert.equal(transactionHistoryView(payment()).amount, "CHF:5.10"); + assert.equal( + transactionHistoryView( + payment({ + amountEffectiveFinal: "CHF:0", + txState: { major: TransactionMajorState.Aborted }, + }), + ).amount, + "CHF:0", + ); +});