commit 6d9b0817a407b11a9dc1997f3aae1e37848cfa32
parent 3583ad5a7fd3b29fe22710106455eb6fbdc99fd1
Author: Florian Dold <florian@dold.me>
Date: Thu, 4 Dec 2025 20:11:25 +0100
wallet-core,harness: fix refund scope issue, add test
This fixes the issue of refund transactions showing up in the
transaction histories for two different exchange scopes.
Existing refund transactions will still show up in both histories, to
fix this, we'd need to add a fixup to rematerialize the transaction
metadata store.
Diffstat:
5 files changed, 297 insertions(+), 31 deletions(-)
diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts
@@ -955,7 +955,7 @@ export async function makeTestPaymentV2(
instance?: string;
},
auth: WithAuthorization = {},
-): Promise<{ transactionId: TransactionIdStr }> {
+): Promise<{ transactionId: TransactionIdStr; orderId: string }> {
// Set up order.
const { walletClient, merchant, instance, merchantAdminAccessToken } = args;
@@ -1011,6 +1011,7 @@ export async function makeTestPaymentV2(
return {
transactionId: preparePayResult.transactionId,
+ orderId: orderResp.order_id,
};
}
diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope-separation.ts b/packages/taler-harness/src/integrationtests/test-currency-scope-separation.ts
@@ -0,0 +1,252 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Duration,
+ ScopeType,
+ TalerCorebankApiClient,
+ TalerMerchantInstanceHttpClient,
+ j2s,
+ succeedOrThrow,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ HarnessExchangeBankAccount,
+ MerchantService,
+ getTestHarnessPaytoForLabel,
+ setupDb,
+} from "../harness/harness.js";
+
+export async function runCurrencyScopeSeparationTest(t: GlobalTestState) {
+ // Set up test environment
+ const dbDefault = await setupDb(t);
+
+ const dbExchangeTwo = await setupDb(t, {
+ nameSuffix: "exchange2",
+ });
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: dbDefault.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8281,
+ database: dbExchangeTwo.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ httpPort: 8083,
+ database: dbDefault.connStr,
+ });
+
+ let exchangeOneBankAccount: HarnessExchangeBankAccount = {
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/myexchange/taler-wire-gateway/",
+ bank.corebankApiBaseUrl,
+ ).href,
+ wireGatewayAuth: {
+ username: "myexchange",
+ password: "password",
+ },
+ accountPaytoUri: getTestHarnessPaytoForLabel("myexchange"),
+ };
+
+ let exchangeTwoBankAccount: HarnessExchangeBankAccount = {
+ wireGatewayApiBaseUrl: new URL(
+ "accounts/myexchange2/taler-wire-gateway/",
+ bank.corebankApiBaseUrl,
+ ).href,
+ wireGatewayAuth: {
+ username: "myexchange2",
+ password: "password",
+ },
+ accountPaytoUri: getTestHarnessPaytoForLabel("myexchange2"),
+ };
+
+ bank.setSuggestedExchange(
+ exchangeOne,
+ exchangeOneBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "admin-password",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: exchangeOneBankAccount.wireGatewayAuth.username,
+ username: exchangeOneBankAccount.wireGatewayAuth.username,
+ password: exchangeOneBankAccount.wireGatewayAuth.password,
+ is_taler_exchange: true,
+ payto_uri: exchangeOneBankAccount.accountPaytoUri,
+ });
+ await exchangeOne.addBankAccount("1", exchangeOneBankAccount);
+
+ await bankClient.registerAccountExtended({
+ name: exchangeTwoBankAccount.wireGatewayAuth.username,
+ username: exchangeTwoBankAccount.wireGatewayAuth.username,
+ password: exchangeTwoBankAccount.wireGatewayAuth.password,
+ is_taler_exchange: true,
+ payto_uri: exchangeTwoBankAccount.accountPaytoUri,
+ });
+ await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount);
+
+ // Set up the first exchange
+
+ exchangeOne.addOfferedCoins(defaultCoinConfig);
+ await exchangeOne.start();
+ await exchangeOne.pingUntilAvailable();
+
+ // Set up the second exchange
+
+ exchangeTwo.addOfferedCoins(defaultCoinConfig);
+ await exchangeTwo.start();
+ await exchangeTwo.pingUntilAvailable();
+
+ // Start and configure merchant
+
+ merchant.addExchange(exchangeOne);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const { accessToken: adminAccessToken } =
+ await merchant.addInstanceWithWireAccount({
+ id: "admin",
+ name: "Default Instance",
+ paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ const w1 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:15",
+ });
+
+ const w2 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: exchangeTwo,
+ amount: "TESTKUDOS:1",
+ });
+
+ await w1.withdrawalFinishedCond;
+ await w2.withdrawalFinishedCond;
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+
+ const payRes = await makeTestPaymentV2(t, {
+ merchant,
+ merchantAdminAccessToken: adminAccessToken,
+ walletClient,
+ order: {
+ amount: "TESTKUDOS:10",
+ summary: "Test",
+ },
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ const refundResp = succeedOrThrow(
+ await merchantClient.addRefund(adminAccessToken, payRes.orderId, {
+ reason: "test",
+ refund: "TESTKUDOS:5",
+ }),
+ );
+
+ await walletClient.call(WalletApiOperation.StartRefundQueryForUri, {
+ talerRefundUri: refundResp.taler_refund_uri,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns1 = await walletClient.call(WalletApiOperation.GetTransactionsV2, {
+ scopeInfo: {
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: exchangeOne.baseUrl,
+ },
+ });
+
+ console.log(`txns1: ${j2s(txns1)}`);
+
+ const txns2 = await walletClient.call(WalletApiOperation.GetTransactionsV2, {
+ scopeInfo: {
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: exchangeTwo.baseUrl,
+ },
+ });
+
+ console.log(`txns2: ${j2s(txns2)}`);
+
+ t.assertDeepEqual(
+ txns1.transactions.map((x) => x.type),
+ ["withdrawal", "payment", "refund"],
+ );
+ t.assertDeepEqual(
+ txns2.transactions.map((x) => x.type),
+ ["withdrawal"],
+ );
+}
+
+runCurrencyScopeSeparationTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
@@ -40,9 +40,6 @@ import {
setupDb,
} from "../harness/harness.js";
-/**
- * Run test for basic, bank-integrated withdrawal and payment.
- */
export async function runCurrencyScopeTest(t: GlobalTestState) {
// Set up test environment
const dbDefault = await setupDb(t);
@@ -156,23 +153,27 @@ export async function runCurrencyScopeTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- const { accessToken: adminAccessToken } = await merchant.addInstanceWithWireAccount({
- id: "admin",
- name: "Default Instance",
- paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
- defaultWireTransferDelay: Duration.toTalerProtocolDuration(
- Duration.fromSpec({ minutes: 1 }),
- ),
- });
+ const { accessToken: adminAccessToken } =
+ await merchant.addInstanceWithWireAccount({
+ id: "admin",
+ name: "Default Instance",
+ paytoUris: [getTestHarnessPaytoForLabel("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
- await merchant.addInstanceWithWireAccount({
- id: "minst1",
- name: "minst1",
- paytoUris: [getTestHarnessPaytoForLabel("minst1")],
- defaultWireTransferDelay: Duration.toTalerProtocolDuration(
- Duration.fromSpec({ minutes: 1 }),
- ),
- }, {adminAccessToken});
+ await merchant.addInstanceWithWireAccount(
+ {
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [getTestHarnessPaytoForLabel("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ },
+ { adminAccessToken },
+ );
const { walletClient } = await createWalletDaemonWithClient(t, {
name: "wallet",
@@ -251,7 +252,8 @@ export async function runCurrencyScopeTest(t: GlobalTestState) {
t.assertDeepEqual(bal2.balances.length, 1);
const payRes = await makeTestPaymentV2(t, {
- merchant, merchantAdminAccessToken: adminAccessToken,
+ merchant,
+ merchantAdminAccessToken: adminAccessToken,
walletClient,
order: {
amount: "TESTKUDOS:10",
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -42,6 +42,7 @@ import { runBankApiTest } from "./test-bank-api.js";
import { runBankWopTest } from "./test-bank-wop.js";
import { runClaimLoopTest } from "./test-claim-loop.js";
import { runClauseSchnorrTest } from "./test-clause-schnorr.js";
+import { runCurrencyScopeSeparationTest } from "./test-currency-scope-separation.js";
import { runCurrencyScopeTest } from "./test-currency-scope.js";
import { runDenomLostComplexTest } from "./test-denom-lost-complex.js";
import { runDenomLostTest } from "./test-denom-lost.js";
@@ -409,6 +410,7 @@ const allTests: TestMainFunction[] = [
runWalletExchangeFeaturesTest,
runRepurchaseV1Test,
runWalletBbanTest,
+ runCurrencyScopeSeparationTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -615,20 +615,22 @@ export class RefundTransactionContext implements TransactionContext {
* Must be called each time the DB record for the transaction is updated.
*/
async updateTransactionMeta(
- tx: WalletDbReadWriteTransaction<["refundGroups", "transactionsMeta"]>,
+ tx: WalletDbReadWriteTransaction<
+ ["refundGroups", "purchases", "transactionsMeta"]
+ >,
): Promise<void> {
const refundRec = await tx.refundGroups.get(this.refundGroupId);
if (!refundRec) {
await tx.transactionsMeta.delete(this.transactionId);
return;
}
+ const purchaseRecord = await tx.purchases.get(refundRec.proposalId);
await tx.transactionsMeta.put({
transactionId: this.transactionId,
status: refundRec.status,
timestamp: refundRec.timestampCreated,
currency: Amounts.currencyOf(refundRec.amountEffective),
- // FIXME!
- exchanges: [],
+ exchanges: purchaseRecord?.exchanges ?? [],
});
}
@@ -655,15 +657,15 @@ export class RefundTransactionContext implements TransactionContext {
}
const purchaseRecord = await tx.purchases.get(refundRecord.proposalId);
+ let scopes: ScopeInfo[] = [];
+ if (purchaseRecord && purchaseRecord.exchanges != null) {
+ scopes = await getScopeForAllExchanges(tx, purchaseRecord.exchanges);
+ }
+
const txState = computeRefundTransactionState(refundRecord);
return {
type: TransactionType.Refund,
- scopes: await getScopeForAllCoins(
- tx,
- !purchaseRecord || !purchaseRecord.payInfo?.payCoinSelection
- ? []
- : purchaseRecord.payInfo.payCoinSelection.coinPubs,
- ),
+ scopes,
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
: refundRecord.amountEffective,
@@ -690,6 +692,7 @@ export class RefundTransactionContext implements TransactionContext {
const res = await wex.db.runReadWriteTx(
{
storeNames: [
+ "purchases",
"refundGroups",
"refundItems",
"tombstones",
@@ -707,7 +710,13 @@ export class RefundTransactionContext implements TransactionContext {
async deleteTransactionInTx(
tx: WalletDbReadWriteTransaction<
- ["refundGroups", "refundItems", "tombstones", "transactionsMeta"]
+ [
+ "purchases",
+ "refundGroups",
+ "refundItems",
+ "tombstones",
+ "transactionsMeta",
+ ]
>,
): Promise<{ notifs: WalletNotification[] }> {
const notifs: WalletNotification[] = [];