View Issue Details

IDProjectCategoryView StatusLast Update
0012146Talerwallet-corepublic2026-11-15 17:24
Reporterbot-cg [agent]  
PrioritynormalSeverityminorReproducibilityalways
Status new 
Product Versiongit (master) 
Summary0012146: wallet-core: a refund URI that spells the merchant instance differently answers "no purchase found for the refund"
DescriptionstartRefundQueryForUri looks the purchase up under the merchant base URL exactly as the taler://refund/ URI spells it. A stored purchase is filed under the canonical (lower-case) instance URL from the contract terms, so a refund URI naming /instances/MyShop/ instead of /instances/myshop/ finds nothing. The wallet then throws WALLET_PURCHASE_NOT_FOUND (7051), the error 0010692 introduced for an order bought with a different wallet, and the refund cannot be collected through that QR code.

0011340 made instance names case-insensitive and fixed the same lookup for re-scanned taler://pay URIs (8e57ab32d, createOrReusePurchase), but the refund lookup was not covered. The backend's own taler_refund_uri and the merchant web UI's refund QR use the canonical spelling, so this hits refund URIs that an integration composes from a base URL configured with different casing.
Steps To ReproduceThe attached patch extends the taler-harness test merchant-instance-casing: after the payment, grant a refund, replace /instances/<id>/ in taler_refund_uri with the upper-case spelling, and call startRefundQueryForUri.

On master 2f657b0ba the test fails with "no purchase found for the refund"; with the patch applied it passes.
Additional InformationAttached fix: the refund lookup tries the URI's spelling and then the canonical one, through a small helper that the existing createOrReusePurchase lookup now shares. Rebased from a July branch; the share-payment part of that branch was dropped because sharePayment has since been replaced by unclaimPayment(proposalId), which needs no URL lookup.
TagsNo tags attached.
Attached Files
0001-wallet-resolve-refund-URIs-across-merchant-instance-casing.patch (5,917 bytes)   
From 616d41d3abb4d84315ea43bd203ade68be620047 Mon Sep 17 00:00:00 2001
From: Florian Dold <dold@taler.net>
Date: Fri, 31 Jul 2026 16:01:41 +0200
Subject: [PATCH] wallet: resolve refund URIs across merchant instance name
 casing

startRefundQueryForUri looked the purchase up under the refund URI's base URL,
so a QR spelling the instance differently reported that the order had been
paid with another wallet.

Issue: https://bugs.taler.net/n/12146
Assisted by LLM
---
 .../test-merchant-instance-casing.ts          | 30 ++++++++++
 .../taler-wallet-core/src/pay-merchant.ts     | 55 ++++++++++++-------
 2 files changed, 65 insertions(+), 20 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts b/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts
index 41963b7c9..afaf25039 100644
--- a/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instance-casing.ts
@@ -378,6 +378,36 @@ export async function runMerchantInstanceCasingTest(t: GlobalTestState) {
     await orderClient.getOrderDetails(instanceToken, orderResp.order_id),
   );
   t.assertDeepEqual(orderStatus.order_status, "paid");
+
+  {
+    // A taler://refund/ URI carries a merchant base URL too, and the wallet
+    // resolves it back to the purchase the same way.  Spelling the instance
+    // differently must still find the payment -- otherwise scanning a refund
+    // QR reports that the order was paid with some other wallet.
+    const refund = succeedOrThrow(
+      await orderClient.addRefund(instanceToken, orderResp.order_id, {
+        refund: "TESTKUDOS:2",
+        reason: "instance casing test",
+      }),
+    );
+    const uncanonicalRefundUri = refund.taler_refund_uri.replace(
+      `/instances/${CANONICAL_ID}/`,
+      `/instances/${UPPER_ID}/`,
+    );
+    t.assertTrue(uncanonicalRefundUri !== refund.taler_refund_uri);
+    console.log(`refund query via: ${uncanonicalRefundUri}`);
+
+    const refundQuery = await walletClient.call(
+      WalletApiOperation.StartRefundQueryForUri,
+      {
+        talerRefundUri: uncanonicalRefundUri,
+      },
+    );
+    t.assertDeepEqual(
+      refundQuery.transactionId,
+      preparePayResult.transactionId,
+    );
+  }
 }
 
 runMerchantInstanceCasingTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index c67d03b65..1d86dddcb 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -1961,6 +1961,24 @@ async function generateSlate(
   });
 }
 
+/**
+ * The spellings of a merchant base URL under which a stored purchase might be
+ * filed: the one we were handed, plus -- when it differs -- the one with the
+ * instance segment folded to lower case.
+ *
+ * A base URL taken from a taler:// URI or passed in by an API caller may spell
+ * the merchant instance in any case, since the merchant accepts all of them.
+ * A stored purchase, however, always holds the canonical spelling that came
+ * back in the contract terms, so a lookup that only tries the caller's
+ * spelling silently misses it.
+ */
+function merchantBaseUrlSpellings(merchantBaseUrl: string): string[] {
+  const canonical = canonicalizeMerchantInstanceUrl(merchantBaseUrl);
+  return canonical === merchantBaseUrl
+    ? [merchantBaseUrl]
+    : [merchantBaseUrl, canonical];
+}
+
 /**
  * Create a new purchase transaction if necessary.  If a purchase
  * record for the provided arguments already exists,
@@ -2027,25 +2045,19 @@ export async function createOrReusePurchase(
   proposalId: string;
   transactionId: TransactionIdStr;
 }> {
-  // Find existing proposals from the same merchant
-  // with the same order ID.
-  //
-  // Once a proposal has been downloaded its merchantBaseUrl is the one from
-  // the contract terms, which always spells the instance in lower case.  A
-  // taler:// URI may spell it differently -- the merchant accepts any casing
-  // -- so look the record up under the canonical spelling as well, or
-  // re-scanning such a URI would claim the order a second time instead of
+  // Find existing proposals from the same merchant with the same order ID.
+  // Once a proposal has been downloaded it is filed under the base URL from
+  // the contract terms, so re-scanning a URI that spells the instance
+  // differently would otherwise claim the order a second time instead of
   // returning the transaction that already exists.
-  const canonicalBaseUrl = canonicalizeMerchantInstanceUrl(merchantBaseUrl);
   const oldProposals = await wex.runWalletDbTx(async (tx) => {
-    const found = await tx.getPurchasesByUrlAndOrderId(
-      merchantBaseUrl,
-      orderId,
-    );
-    if (found.length > 0 || canonicalBaseUrl === merchantBaseUrl) {
-      return found;
+    for (const baseUrl of merchantBaseUrlSpellings(merchantBaseUrl)) {
+      const found = await tx.getPurchasesByUrlAndOrderId(baseUrl, orderId);
+      if (found.length > 0) {
+        return found;
+      }
     }
-    return tx.getPurchasesByUrlAndOrderId(canonicalBaseUrl, orderId);
+    return [];
   });
 
   if (oldProposals.length > 1) {
@@ -6156,10 +6168,13 @@ export async function startRefundQueryForUri(
     );
   }
   const purchaseRecord = await wex.runWalletDbTx(async (tx) => {
-    return tx.getPurchaseByUrlAndOrderId(
-      parsedUri.merchantBaseUrl,
-      parsedUri.orderId,
-    );
+    for (const baseUrl of merchantBaseUrlSpellings(parsedUri.merchantBaseUrl)) {
+      const p = await tx.getPurchaseByUrlAndOrderId(baseUrl, parsedUri.orderId);
+      if (p) {
+        return p;
+      }
+    }
+    return undefined;
   });
   if (!purchaseRecord) {
     // Scanning a refund QR code for an order that was paid with a different
-- 
2.47.3

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

related to 0011340 closedFlorian Dold instance names are/were case-sensitive (but should be case-insensitive) 
related to 0010692 closedFlorian Dold wallet-core should return proper error code when no purchase is found for refund 

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-15 17:23bot-cgNew Issue
2026-11-15 17:23bot-cgRelationship addedrelated to 0010692
2026-11-15 17:23bot-cgRelationship addedrelated to 0011340
2026-11-15 17:23bot-cgImplementer => avalos
2026-11-15 17:23bot-cgReviewer => oec
2026-11-15 17:23bot-cgRelease signed by => Florian Dold
2026-11-15 17:23bot-cgDeployed by => oec
2026-11-15 17:23bot-cgVerifier => oec
2026-11-15 17:23bot-cgCommunicator => CM_7
2026-11-15 17:24bot-cgFile Added: 0001-wallet-resolve-refund-URIs-across-merchant-instance-casing.patch