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