taler-typescript-core

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

commit f86862aaad9af0275bd18f7051f82b61dcdb8fe4
parent bfaa412233c07b922bb147c7e115260988e9a3b6
Author: Florian Dold <dold@taler.net>
Date:   Wed, 12 Aug 2026 16:57:43 +0200

wallet-core: classify test balances correctly

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 2+-
Apackages/taler-wallet-core/src/balance.test.ts | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/balance.ts | 27+++++++++++----------------
Mpackages/taler-wallet-core/src/builtin-exchanges.ts | 18+++++++++++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 11+++++++++--
5 files changed, 191 insertions(+), 20 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -659,7 +659,7 @@ export interface DonauSummaryItem { export interface BalancesResponse { /** Electronic cash balances, per currency scope. */ balances: WalletBalance[]; - /** Does the user have non-demo money? */ + /** Does the user have money from an exchange other than demo or test? */ haveProdBalance: boolean; /* Summary of donations, per donau/year/currency. */ donauSummary?: DonauSummaryItem[]; diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts @@ -0,0 +1,153 @@ +/* + 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 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 { ScopeType } from "@gnu-taler/taler-util"; +import { getBalancesInsideTransaction } from "./balance.js"; +import { + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + WalletExchangeDetails, + WalletExchangeEntry, +} from "./db-common.js"; +import { WalletDbTransaction } from "./dbtx.js"; +import { WalletExecutionContext } from "./wallet.js"; + +function makeExchange( + baseUrl: string, + presetType?: string, +): WalletExchangeEntry { + return { + baseUrl, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + presetType, + } as WalletExchangeEntry; +} + +function makeBalanceContext(exchanges: WalletExchangeEntry[]): { + wex: WalletExecutionContext; + tx: WalletDbTransaction; +} { + const detailsByUrl = new Map( + exchanges.map((x) => [ + x.baseUrl, + { + exchangeBaseUrl: x.baseUrl, + currency: "TESTKUDOS", + masterPublicKey: `master-pub-${x.baseUrl}`, + } as WalletExchangeDetails, + ]), + ); + const tx = { + async getDonationSummaries() { + return []; + }, + async getExchanges() { + return exchanges; + }, + async getExchangeDetails(baseUrl: string) { + return detailsByUrl.get(baseUrl); + }, + async getExchangeScopeInfo(baseUrl: string, currency: string) { + return { type: ScopeType.Exchange, currency, url: baseUrl }; + }, + async getCoinAvailabilities() { + return []; + }, + async getActiveRefreshGroups() { + return []; + }, + async getActiveWithdrawalGroups() { + return []; + }, + async getActivePeerPushDebits() { + return []; + }, + async getActivePeerPushCredits() { + return []; + }, + async getActivePeerPullCredits() { + return []; + }, + async getActivePeerPullDebits() { + return []; + }, + async getActivePurchases() { + return []; + }, + async getActiveDepositGroups() { + return []; + }, + } as unknown as WalletDbTransaction; + const wex = { + ws: { devExperimentState: {} }, + } as WalletExecutionContext; + return { wex, tx }; +} + +test("haveProdBalance classifies demo, test, and production exchanges", async () => { + const cases: Array<{ + name: string; + exchanges: WalletExchangeEntry[]; + expected: boolean; + }> = [ + { name: "empty wallet", exchanges: [], expected: false }, + { + name: "builtin test exchange without persisted type", + exchanges: [makeExchange("https://exchange.test.taler.net/")], + expected: false, + }, + { + name: "builtin test exchange overrides stale production type", + exchanges: [makeExchange("https://exchange.test.taler.net/", "prod")], + expected: false, + }, + { + name: "demo exchange", + exchanges: [makeExchange("https://exchange.demo.taler.net/")], + expected: false, + }, + { + name: "builtin production exchange", + exchanges: [makeExchange("https://exchange.taler-ops.ch/")], + expected: true, + }, + { + name: "unclassified custom exchange", + exchanges: [makeExchange("https://exchange.example/")], + expected: true, + }, + { + name: "test and production exchanges", + exchanges: [ + makeExchange("https://exchange.test.taler.net/"), + makeExchange("https://exchange.example/"), + ], + expected: true, + }, + ]; + + for (const testCase of cases) { + const { wex, tx } = makeBalanceContext(testCase.exchanges); + const result = await getBalancesInsideTransaction(wex, tx); + assert.strictEqual( + result.haveProdBalance, + testCase.expected, + testCase.name, + ); + } +}); diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -93,6 +93,7 @@ import { WithdrawalRecordType, WalletDonationSummary, } from "./db-common.js"; +import { getEffectiveExchangeType } from "./builtin-exchanges.js"; import {} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; @@ -113,7 +114,6 @@ interface WalletBalance { disablePeerPayments: boolean; disableDirectDeposits: boolean; shoppingUrls: Set<string>; - isProd: boolean; } function computeRefreshGroupAvailableAmountForExchanges( @@ -174,7 +174,6 @@ function getScopeSortingOrder(scopeInfo: ScopeInfo): number { } class BalancesStore { - private nonDemoExchanges: Set<string> = new Set(); private exchangeScopeCache: Record<string, ScopeInfo> = {}; private balanceStore: Record<string, WalletBalance> = {}; private donauSummaryItems: DonauSummaryItem[] | undefined = undefined; @@ -184,10 +183,6 @@ class BalancesStore { private tx: WalletDbTransaction, ) {} - setNonDemoExchange(baseUrl: string) { - this.nonDemoExchanges.add(baseUrl); - } - /** * Add amount to a balance field, both for * the slicing by exchange and currency. @@ -235,10 +230,8 @@ class BalancesStore { disablePeerPayments: false, disableDirectDeposits: false, shoppingUrls: new Set(), - isProd: false, }; } - b.isProd = b.isProd || this.nonDemoExchanges.has(exchangeBaseUrl); return this.balanceStore[balanceKey]; } @@ -376,10 +369,10 @@ class BalancesStore { }); } - toBalancesResponse(): BalancesResponse { + toBalancesResponse(haveProdBalance: boolean): BalancesResponse { const balancesResponse: BalancesResponse = { balances: [], - haveProdBalance: false, + haveProdBalance, }; if (this.donauSummaryItems) { @@ -427,9 +420,6 @@ class BalancesStore { } else { disablePeerPayments = v.disablePeerPayments; } - if (v.isProd) { - balancesResponse.haveProdBalance = true; - } balancesResponse.balances.push({ scopeInfo: v.scopeInfo, available: Amounts.stringify(v.available), @@ -453,6 +443,7 @@ export async function getBalancesInsideTransaction( tx: WalletDbTransaction, ): Promise<BalancesResponse> { const balanceStore: BalancesStore = new BalancesStore(wex, tx); + let haveProdBalance = false; const donationSummaries = await tx.getDonationSummaries(); for (const rec of donationSummaries) { @@ -467,8 +458,12 @@ export async function getBalancesInsideTransaction( ) { const det = await tx.getExchangeDetails(ex.baseUrl); if (det) { - if (ex.presetType == null || ex.presetType === "prod") { - balanceStore.setNonDemoExchange(ex.baseUrl); + const exchangeType = getEffectiveExchangeType( + ex.baseUrl, + ex.presetType, + ); + if (exchangeType !== "demo" && exchangeType !== "test") { + haveProdBalance = true; } await balanceStore.addZero(det.currency, ex.baseUrl); if (ex.peerPaymentsDisabled) { @@ -798,7 +793,7 @@ export async function getBalancesInsideTransaction( } } - return balanceStore.toBalancesResponse(); + return balanceStore.toBalancesResponse(haveProdBalance); } /** diff --git a/packages/taler-wallet-core/src/builtin-exchanges.ts b/packages/taler-wallet-core/src/builtin-exchanges.ts @@ -16,11 +16,13 @@ import { CurrencySpecification } from "@gnu-taler/taler-util"; +export type BuiltinExchangeType = "demo" | "prod" | "test"; + export interface BuiltinExchange { exchangeBaseUrl: string; currencyHint: string; currencySpec?: CurrencySpecification; - type: "demo" | "prod" | "test"; + type: BuiltinExchangeType; versionAdded: number; } @@ -58,3 +60,17 @@ export const builtinExchanges: BuiltinExchange[] = [ }, }, ]; + +/** + * Return the exchange type from the builtin catalog when available, otherwise + * fall back to the type stored on the wallet's exchange entry. + */ +export function getEffectiveExchangeType( + exchangeBaseUrl: string, + presetType: string | undefined, +): string | undefined { + return ( + builtinExchanges.find((x) => x.exchangeBaseUrl === exchangeBaseUrl)?.type ?? + presetType + ); +} diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -123,7 +123,11 @@ import { HttpRequestLibrary, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; -import { builtinExchanges, type BuiltinExchange } from "./builtin-exchanges.js"; +import { + builtinExchanges, + getEffectiveExchangeType, + type BuiltinExchange, +} from "./builtin-exchanges.js"; import { PendingTaskType, TaskIdStr, @@ -3560,7 +3564,10 @@ export async function listWithdrawalExchangeCandidates( if (req.presetOnly) { return builtin?.type === "prod"; } - const presetType = builtin?.type ?? record.presetType; + const presetType = getEffectiveExchangeType( + item.exchangeBaseUrl, + record.presetType, + ); if ( presetType === "demo" || presetType === "test" ||