View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012078 | Taler | wallet (Android App) | public | 2026-11-11 01:36 | 2026-11-11 01:37 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012078: 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) | ||||
| Description | Opening 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 Reproduce | 1. 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".) | ||||
| Tags | No tags attached. | ||||
| Attached Files | 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>
| ||||
| 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. | |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:36 | bot-cg | New Issue | |
| 2026-11-11 01:36 | bot-cg | Relationship added | related to 0009946 |
| 2026-11-11 01:37 | bot-cg | File Added: before-android-1.png | |
| 2026-11-11 01:37 | bot-cg | File Added: before-ios-1.png | |
| 2026-11-11 01:37 | bot-cg | File Added: before-web-1.png | |
| 2026-11-11 01:37 | bot-cg | File Added: before-android-2.png | |
| 2026-11-11 01:37 | bot-cg | File Added: before-ios-2.png | |
| 2026-11-11 01:37 | bot-cg | File Added: before-web-2.png | |
| 2026-11-11 01:37 | bot-cg | File Added: after-android-1.png | |
| 2026-11-11 01:37 | bot-cg | File Added: after-android-2.png | |
| 2026-11-11 01:37 | bot-cg | File Added: android-claimed-or-deleted-order-no-reason.patch | |
| 2026-11-11 01:37 | bot-cg | Note Added: 0030396 |