View Issue Details

IDProjectCategoryView StatusLast Update
0012078Talerwallet (Android App)public2026-11-11 01:37
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
Summary0012078: Android: an order already claimed by another wallet (or deleted by the merchant) shows only "This transaction has failed or been abandoned" and "UNKNOWN 0.00" -- the reason is dropped (regression of 0009946, patch attached)
DescriptionOpening the pay link of an order that another wallet has already claimed (a QR code scanned with a second phone, a link opened twice), or of an order the merchant has deleted or that never existed, ends in a failed claim. wallet-core keeps it as a FailedClaim transaction (state failed/claim-proposal, actions [delete]) with failReason = WALLET_ORDER_ALREADY_CLAIMED (7014) or WALLET_MERCHANT_ORDER_NOT_FOUND (7049) (pay-merchant.ts:1576-1606, failProposalClaimPermanently :1236-1266). The WebExtension explains it; Android and iOS only say that the transaction failed.

What the user sees (origin/master builds of 2026-11-11; the order is claimed first by a taler-wallet-cli wallet, resp. deleted at the merchant before the link is opened):
- WebExtension: "Payment could not continue" -- "This order was already claimed by another wallet." -- "Return to the merchant and request a new order." (before-web-1.png); deleted order: "The merchant order no longer exists or has expired." -- "Return to the merchant and create a new order." (before-web-2.png).
- Android: "This transaction has failed or been abandoned", date, "Total UNKNOWN 0.00", "Paid UNKNOWN 0.00", Delete (before-android-1.png, before-android-2.png). The reason is not shown even in developer mode's banner; the placeholder amounts come from wallet-core's contract-less transaction.
- iOS: "Payment", date, "Abandoned", Done -- nothing else (before-ios-1.png, before-ios-2.png).

On Android this is a regression of 0009946 ("No info if QR already was claimed by another wallet", fixed 2025 in 88c3ef49). wallet-core 70bfb888b (2026-08-29, "payments: retain failed claims to avoid races in the UI") changed a failed claim from "delete the transaction (causeHint claim-failed)" to "move it to FailedClaim"; the WebExtension was updated in the same commit. Android's b5b77ef3 of the same night still waits for the old shape:
- wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt:439-451, 497-502: isClaimFailureDeletion() requires newTxState.major == Deleted, which no longer happens (the bundled qtart 1.6.49 already has FailedClaim). The error path is dead.
- wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt:444-456: TransactionPayment does not decode failReason.
- wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt:112-139: state banner and the (placeholder) amounts; :150-153 error only in developer mode.
Reference: web routes/App.tsx:4416-4432, routes/transaction-model.ts:383-394, ui/error.ts:24-29, 83-88.

Patch (android-claimed-or-deleted-order-no-reason.patch):
- TransactionPayment decodes failReason.
- The purchase detail shows the reason for a contract-less failed claim: 7014 -> "This order was already claimed by another wallet." / "Return to the merchant and request a new order."; 7049 -> "The merchant order no longer exists or has expired." / "Return to the merchant and create a new order."; any other code -> failReason's message. Four new English strings (the WebExtension's wording).
- The Total/Paid rows are only shown when there is a contract (no more "UNKNOWN 0.00").
The dead isClaimFailureDeletion() path is left alone; it can be removed or pointed at Failed/ClaimProposal.
Verified: :wallet:assembleFdroidSim builds; after-android-1.png and after-android-2.png are the same two scenarios with the patch.

Related: 0009946 (closed, same symptom), 0012067 (generic: transaction error shown in developer mode only), iOS counterpart filed separately.

Assisted by LLM
Steps To Reproduce1. Create an order (any merchant) and open its taler://pay/ link in one wallet (or claim it with taler-wallet-cli), without paying.
2. Open the same link in the wallet under test: failed/Abandoned without a reason.
3. Variant: delete the order at the merchant (or change the order id in the link) and open it.
(Automated: U=$(wc-stack pay-uri --amount KUDOS:1); wc-stack claim-elsewhere "$U"; wc-android uri "$U".)
TagsNo tags attached.
Attached Files
before-android-1.png (61,442 bytes)   
before-android-1.png (61,442 bytes)   
before-ios-1.png (121,529 bytes)   
before-ios-1.png (121,529 bytes)   
before-web-1.png (62,560 bytes)   
before-web-1.png (62,560 bytes)   
before-android-2.png (61,059 bytes)   
before-android-2.png (61,059 bytes)   
before-ios-2.png (121,544 bytes)   
before-ios-2.png (121,544 bytes)   
before-web-2.png (62,248 bytes)   
before-web-2.png (62,248 bytes)   
after-android-1.png (59,431 bytes)   
after-android-1.png (59,431 bytes)   
after-android-2.png (60,902 bytes)   
after-android-2.png (60,902 bytes)   
android-claimed-or-deleted-order-no-reason.patch (6,589 bytes)   
diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
index 984a97f8..8e9fb763 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Dialog
@@ -47,6 +48,8 @@ import net.taler.lib.android.toAbsoluteTime
 import net.taler.wallet.BottomInsetsSpacer
 import net.taler.wallet.R
 import net.taler.wallet.backend.TalerErrorCode
+import net.taler.wallet.backend.TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND
+import net.taler.wallet.backend.TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED
 import net.taler.wallet.backend.TalerErrorInfo
 import net.taler.wallet.balances.ScopeInfo
 import net.taler.wallet.compose.ErrorComposable
@@ -111,33 +114,41 @@ fun TransactionPaymentComposable(
     ) {
         TransactionStateComposable(state = t.txState)
 
+        // A claim that failed permanently (e.g. the order was already claimed
+        // by another wallet) has no contract, hence no amounts, but a reason.
+        if (t.info == null && t.failReason != null) {
+            ClaimFailureReason(t.failReason)
+        }
+
         Text(
             modifier = Modifier.padding(16.dp),
             text = t.timestamp.ms.toAbsoluteTime(LocalContext.current).toString(),
             style = MaterialTheme.typography.bodyLarge,
         )
 
-        TransactionAmountComposable(
-            label = stringResource(id = R.string.transaction_order_total),
-            amount = t.amountRaw,
-            amountType = AmountType.Neutral,
-        )
+        if (t.info != null) {
+            TransactionAmountComposable(
+                label = stringResource(id = R.string.transaction_order_total),
+                amount = t.amountRaw,
+                amountType = AmountType.Neutral,
+            )
+
+            if (t.amountEffective > t.amountRaw) {
+                val fee = t.amountEffective - t.amountRaw
+                TransactionAmountComposable(
+                    label = stringResource(id = R.string.amount_fee),
+                    amount = fee,
+                    amountType = AmountType.Negative,
+                )
+            }
 
-        if (t.amountEffective > t.amountRaw) {
-            val fee = t.amountEffective - t.amountRaw
             TransactionAmountComposable(
-                label = stringResource(id = R.string.amount_fee),
-                amount = fee,
+                label = stringResource(id = R.string.transaction_paid),
+                amount = t.amountEffective,
                 amountType = AmountType.Negative,
             )
         }
 
-        TransactionAmountComposable(
-            label = stringResource(id = R.string.transaction_paid),
-            amount = t.amountEffective,
-            amountType = AmountType.Negative,
-        )
-
         if (t.posConfirmation != null) PayTotpComposable(
             totpString = t.posConfirmation,
             enableNfc = t.posConfirmationViaNfc == true,
@@ -156,6 +167,34 @@ fun TransactionPaymentComposable(
     }
 }
 
+@Composable
+private fun ClaimFailureReason(failReason: TalerErrorInfo) {
+    val (message, hint) = when (failReason.code) {
+        WALLET_ORDER_ALREADY_CLAIMED -> stringResource(R.string.payment_claimed_by_other_wallet) to
+            stringResource(R.string.payment_claimed_by_other_wallet_hint)
+        WALLET_MERCHANT_ORDER_NOT_FOUND -> stringResource(R.string.payment_order_not_found) to
+            stringResource(R.string.payment_order_not_found_hint)
+        else -> failReason.userFacingMsg to null
+    }
+    Column(
+        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+        horizontalAlignment = CenterHorizontally,
+    ) {
+        Text(
+            text = message,
+            style = MaterialTheme.typography.bodyLarge,
+            color = MaterialTheme.colorScheme.error,
+            textAlign = TextAlign.Center,
+        )
+        if (hint != null) Text(
+            modifier = Modifier.padding(top = 8.dp),
+            text = hint,
+            style = MaterialTheme.typography.bodyMedium,
+            textAlign = TextAlign.Center,
+        )
+    }
+}
+
 @Composable
 fun TransactionPaymentPrompt(
     payStatus: PayStatus,
diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
index d5d397c5..73d1134b 100644
--- a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
+++ b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt
@@ -448,6 +448,7 @@ data class TransactionPayment(
     override val txActions: List<TransactionAction>,
     val info: TransactionInfo? = null,
     override val error: TalerErrorInfo? = null,
+    val failReason: TalerErrorInfo? = null,
     override val amountRaw: Amount,
     override val amountEffective: Amount,
     override val scopes: List<ScopeInfo>,
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
index 89750709..93cbf349 100644
--- a/wallet/src/main/res/values/strings.xml
+++ b/wallet/src/main/res/values/strings.xml
@@ -319,6 +319,10 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
     <string name="payment_tokens_insufficient">Insufficient tokens</string>
     <string name="payment_tokens_unexpected">Unexpected merchant %1$s, your token could be stolen.</string>
     <string name="payment_tokens_untrusted">Merchant %1$s is untrusted, cannot spend this token.</string>
+    <string name="payment_claimed_by_other_wallet">This order was already claimed by another wallet.</string>
+    <string name="payment_claimed_by_other_wallet_hint">Return to the merchant and request a new order.</string>
+    <string name="payment_order_not_found">The merchant order no longer exists or has expired.</string>
+    <string name="payment_order_not_found_hint">Return to the merchant and create a new order.</string>
     <string name="payment_template_error">Error creating order</string>
     <string name="payment_title">Payment</string>
 

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

related to 0009946 closedavalos No info if QR already was claimed by another wallet 
related to 0012079 new iOS: an order already claimed by another wallet (or deleted by the merchant) shows only "Abandoned" -- the reason is never displayed (patch attached) 

Activities

bot-cg [agent]

2026-11-11 01:37

updater   ~0030396

Precision on "not shown even in developer mode": with developer mode on, the screen is the same plus a "Show JSON" button; there is no error button because t.error is null for a failed claim -- the reason (failReason, 7014) is only visible in the raw JSON.

Issue History

Date ModifiedUsernameFieldChange
2026-11-11 01:36bot-cgNew Issue
2026-11-11 01:36bot-cgRelationship addedrelated to 0009946
2026-11-11 01:37bot-cgFile Added: before-android-1.png
2026-11-11 01:37bot-cgFile Added: before-ios-1.png
2026-11-11 01:37bot-cgFile Added: before-web-1.png
2026-11-11 01:37bot-cgFile Added: before-android-2.png
2026-11-11 01:37bot-cgFile Added: before-ios-2.png
2026-11-11 01:37bot-cgFile Added: before-web-2.png
2026-11-11 01:37bot-cgFile Added: after-android-1.png
2026-11-11 01:37bot-cgFile Added: after-android-2.png
2026-11-11 01:37bot-cgFile Added: android-claimed-or-deleted-order-no-reason.patch
2026-11-11 01:37bot-cgNote Added: 0030396