From 616d41d3abb4d84315ea43bd203ade68be620047 Mon Sep 17 00:00:00 2001 From: Florian Dold 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