taler-typescript-core

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

commit a4b084464d5895d6694d56f4fd0461e2c23e3039
parent d0e6c55fdfd82cfd7cb1e1fcd8fd076b47c51055
Author: Florian Dold <dold@taler.net>
Date:   Wed,  4 Nov 2026 09:28:39 +0100

wallet-core: return scoped balances for payment templates

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

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-payment-template.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 15+++++++++++++++
Mpackages/taler-wallet-core/src/pay-template.test.ts | 207++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-template.ts | 11+++++++++++
4 files changed, 273 insertions(+), 1 deletion(-)

diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts @@ -18,7 +18,9 @@ * Imports. */ import { + Amounts, AmountString, + CheckPayTemplateReponse, ConfirmPayResultType, Duration, TalerMerchantInstanceHttpClient, @@ -84,6 +86,21 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { const talerPayTemplateUri = `taler+http://pay-template/localhost:${merchant.port}/template1`; + const assertTemplateBalances = async (result: CheckPayTemplateReponse) => { + const { balances } = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + t.assertDeepEqual( + result.availableBalances, + balances + .filter((b) => + result.supportedCurrencies.includes(b.scopeInfo.currency), + ) + .map((b) => ({ scopeInfo: b.scopeInfo, amountAvailable: b.available })), + ); + }; + const checkPayTemplateResult = await walletClient.call( WalletApiOperation.CheckPayForTemplate, { @@ -95,6 +112,14 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { checkPayTemplateResult.templateDetails.template_contract.summary, mySummary, ); + await assertTemplateBalances(checkPayTemplateResult); + t.assertTrue(checkPayTemplateResult.availableBalances.length === 1); + t.assertTrue( + Amounts.cmp( + checkPayTemplateResult.availableBalances[0].amountAvailable, + "TESTKUDOS:0", + ) > 0, + ); // Request a template payment @@ -145,6 +170,21 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { t.assertTrue(orderStatus.order_status === "paid"); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const afterPayment = await walletClient.call( + WalletApiOperation.CheckPayForTemplate, + { + talerPayTemplateUri, + }, + ); + await assertTemplateBalances(afterPayment); + t.assertTrue( + Amounts.cmp( + afterPayment.availableBalances[0].amountAvailable, + checkPayTemplateResult.availableBalances[0].amountAvailable, + ) < 0, + ); + // Now test overrides from URIs. { const talerPayTemplateUriAmt = `taler+http://pay-template/localhost:${merchant.port}/template1?amount=TESTKUDOS:5`; @@ -159,6 +199,7 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { console.log(j2s(checkPayTemplateResult)); const tc = checkPayTemplateResult.templateDetails.template_contract; + await assertTemplateBalances(checkPayTemplateResult); t.assertDeepEqual(tc.template_type, TemplateType.FIXED_ORDER); t.assertTrue(!!tc.amount); diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -3083,6 +3083,21 @@ export interface CheckPayTemplateRequest { export type CheckPayTemplateReponse = { templateDetails: WalletTemplateDetailsResponse; supportedCurrencies: string[]; + + /** + * Wallet balance snapshots in backend-supported currencies, per scope. + * Uses getBalances.available semantics, including expected pending refresh + * outputs. Preserves known zero balances; empty if no matching scopes exist. + * + * These are not payment maxima: merchant acceptance, age restrictions and + * customer-paid fees are checked during payment preparation. Template + * currency requirements still apply, and balances may change after this + * response. Advisory exchange_candidates do not restrict these entries. + */ + availableBalances: { + scopeInfo: ScopeInfo; + amountAvailable: AmountString; + }[]; }; export const codecForCheckPayTemplateRequest = diff --git a/packages/taler-wallet-core/src/pay-template.test.ts b/packages/taler-wallet-core/src/pay-template.test.ts @@ -16,8 +16,12 @@ import { AmountString, + Amounts, CancellationToken, HostPortPath, + RefreshReason, + ScopeType, + TalerMerchantInstanceHttpClient, TalerPayTemplateUri, TalerUriAction, TalerUris, @@ -29,14 +33,215 @@ import { WalletTemplateDetailsResponse, } from "@gnu-taler/taler-util"; import assert from "node:assert"; -import { test } from "node:test"; +import { test, TestContext } from "node:test"; import { applyTemplateUriOverrides, + checkPayForTemplate, instantiateTemplateRaw, } from "./pay-template.js"; +import { + ExchangeEntryDbRecordStatus, + RefreshOperationStatus, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { ProgressContext } from "./progress.js"; import { WalletExecutionContext } from "./wallet.js"; +/** Exercise the real balance calculation with isolated database inputs. */ +function templateBalanceContext( + t: TestContext, + templateDetails: WalletTemplateDetailsResponse, + holdings: { url: string; amount: AmountString }[] = [], +): WalletExecutionContext { + t.mock.method( + TalerMerchantInstanceHttpClient.prototype, + "useTemplateGetInfo", + async () => ({ type: "ok", body: structuredClone(templateDetails) }), + ); + t.mock.method( + TalerMerchantInstanceHttpClient.prototype, + "getConfig", + async () => ({ + type: "ok", + // The default currency must count even when absent from the map. + body: { currency: "KUDOS", currencies: { EUR: {} } }, + }), + ); + const tx = { + listGlobalCurrencyExchanges: async () => [], + listGlobalCurrencyAuditors: async () => [], + getDonationSummaries: async () => [], + getExchanges: async () => + holdings.map((h) => ({ + baseUrl: h.url, + entryStatus: ExchangeEntryDbRecordStatus.Used, + })), + getExchangeDetails: async (url: string) => { + const h = holdings.find((h) => h.url === url); + return ( + h && { + exchangeBaseUrl: h.url, + currency: Amounts.currencyOf(h.amount), + masterPublicKey: "master-pub", + auditors: [], + } + ); + }, + getVisibleCoinAvailabilities: async () => + holdings + .filter((h) => !Amounts.isZero(h.amount)) + .map((h) => ({ + exchangeBaseUrl: h.url, + exchangeMasterPub: "master-pub", + currency: Amounts.currencyOf(h.amount), + value: h.amount, + visibleCoinCount: 1, + })), + getActiveRefreshGroups: async () => [], + getActiveWithdrawalGroups: async () => [], + getActivePeerPushDebits: async () => [], + getActivePeerPushCredits: async () => [], + getActivePeerPullCredits: async () => [], + getActivePeerPullDebits: async () => [], + getActivePurchases: async () => [], + getActiveDepositGroups: async () => [], + } as unknown as WalletDbTransaction; + return { + cancellationToken: CancellationToken.CONTINUE, + http: { + fetch: async () => { + throw Error("unexpected HTTP request"); + }, + }, + ws: { devExperimentState: {} }, + runWalletDbTx: async <T>(f: (tx: WalletDbTransaction) => Promise<T>) => + f(tx), + } as unknown as WalletExecutionContext; +} + +const templateCheckRequest = { + talerPayTemplateUri: "taler://pay-template/merchant.example/template-id", +}; + +for (const template_contract of [ + { template_type: TemplateType.FIXED_ORDER }, + { + template_type: TemplateType.FIXED_ORDER, + amount: "KUDOS:9" as AmountString, + }, + { template_type: TemplateType.INVENTORY_CART }, + { template_type: TemplateType.PAIVANA, choices: [] }, +] satisfies TemplateContractDetails[]) { + test(`template balances preserve scopes and zeros: ${JSON.stringify(template_contract)}`, async (t) => { + const wex = templateBalanceContext( + t, + { + template_contract, + exchange_candidates: [], + }, + [ + { url: "https://b.example/", amount: "KUDOS:0" }, + { url: "https://a.example/", amount: "KUDOS:5" }, + { url: "https://e.example/", amount: "EUR:2" }, + { url: "https://u.example/", amount: "USD:10" }, + ], + ); + const result = await checkPayForTemplate(wex, templateCheckRequest); + assert.deepStrictEqual(result.supportedCurrencies, ["EUR", "KUDOS"]); + assert.deepStrictEqual(result.availableBalances, [ + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "EUR", + url: "https://e.example/", + }, + amountAvailable: "EUR:2", + }, + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "KUDOS", + url: "https://a.example/", + }, + amountAvailable: "KUDOS:5", + }, + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "KUDOS", + url: "https://b.example/", + }, + amountAvailable: "KUDOS:0", + }, + ]); + assert.deepStrictEqual( + result.templateDetails.template_contract, + template_contract, + ); + }); +} + +test("template balances include pending refresh outputs without exchange candidates", async (t) => { + const wex = templateBalanceContext( + t, + { + template_contract: { template_type: TemplateType.FIXED_ORDER }, + }, + [{ url: "https://a.example/", amount: "KUDOS:5" }], + ); + await wex.runWalletDbTx(async (tx) => { + t.mock.method(tx, "getActiveRefreshGroups", async () => [ + { + currency: "KUDOS", + reason: RefreshReason.PayMerchant, + operationStatus: RefreshOperationStatus.Pending, + infoPerExchange: { + "https://a.example/": { outputEffective: "KUDOS:2" }, + }, + }, + ]); + }); + const result = await checkPayForTemplate(wex, { + talerPayTemplateUri: `${templateCheckRequest.talerPayTemplateUri}?amount=KUDOS:8`, + }); + assert.strictEqual(result.availableBalances[0].amountAvailable, "KUDOS:7"); + assert.deepStrictEqual(result.templateDetails.template_contract, { + template_type: TemplateType.FIXED_ORDER, + amount: "KUDOS:8", + }); +}); + +test("template balances are empty when there are no matching scopes", async (t) => { + for (const holdings of [ + [], + [{ url: "https://u.example/", amount: "USD:10" as AmountString }], + ]) { + const wex = templateBalanceContext( + t, + { + template_contract: { template_type: TemplateType.FIXED_ORDER }, + }, + holdings, + ); + const result = await checkPayForTemplate(wex, templateCheckRequest); + assert.deepStrictEqual(result.availableBalances, []); + } +}); + +test("template balance database errors are not reported as empty balances", async (t) => { + const wex = templateBalanceContext(t, { + template_contract: { template_type: TemplateType.FIXED_ORDER }, + }); + const failure = Error("database unavailable"); + t.mock.method(wex, "runWalletDbTx", async () => { + throw failure; + }); + await assert.rejects( + checkPayForTemplate(wex, templateCheckRequest), + (e) => e === failure, + ); +}); + function paivanaUri(amount: AmountString): TalerPayTemplateUri { return { type: TalerUriAction.PayTemplate, diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts @@ -44,6 +44,7 @@ import { runWithProgressRetries, } from "./progress.js"; import { WalletExecutionContext } from "./wallet.js"; +import { getBalancesInsideTransaction } from "./balance.js"; import { canonicalizePaivanaUrl, isValidPaivanaSessionId, @@ -238,9 +239,19 @@ async function internalCheckPayForTemplate( editableDefaults, ); + const { balances } = await wex.runWalletDbTx((tx) => + getBalancesInsideTransaction(wex, tx, false), + ); + return { templateDetails, supportedCurrencies: [...currSet].sort(), + availableBalances: balances + .filter((balance) => currSet.has(balance.scopeInfo.currency)) + .map((balance) => ({ + scopeInfo: balance.scopeInfo, + amountAvailable: balance.available, + })), }; }