View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011977 | Taler | merchant-pos-terminal (Android App) | public | 2026-11-09 08:55 | 2026-11-09 08:55 |
| 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 | 0011977: Any failed order delete tells the cashier "This order has been claimed by a wallet", including a plain network error | ||||
| Description | When the cashier cancels an order and the DELETE fails for *any* reason, the terminal tells them a wallet has claimed it: Force delete order? This order has been claimed by a wallet. Force deleting it will release any inventory locks and prevent the wallet from completing the payment. [Cancel payment] [Force delete] That sentence is produced unconditionally. The terminal never asks the backend *why* the delete failed, so a network error, a 5xx, an expired token and a 404 all say a wallet claimed the order. Observed with the backend saying the opposite at the same moment. From the simulator's episode for this run, the order id and the backend's own view of it recorded side by side with the dialog: order on screen: 2026.313-ZHY9Z0163KAHM backend on 2026.313-ZHY9Z0163KAHM: order_status='unpaid' after cancelling: Force delete order? | This order has been claimed by a wallet. ... No wallet had claimed it, none ever had, and the order was still there afterwards -- so "Cancel order" left a live order behind while telling the cashier a wallet had taken it. Reproduced three times, same screen each time. WHY IT HAPPENS merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt, in tryDeleteOrder(): val result = api.deleteOrder(merchantConfig, orderId) result.handle( onFailure = { errorStr -> Log.w(TAG, "Delete order $orderId failed: $errorStr") mDeleteNeedsForce.postValue(true) // <- every failure }, `mDeleteNeedsForce = true` is the only failure branch; the reason is logged and discarded. ProcessPaymentFragment turns that flag straight into the force-delete dialog, whose message is force_delete_dialog_message in strings.xml ("This order has been claimed by a wallet..."). The same shape is in history/HistoryManager.kt, deleteOrder(), where the error string is not even bound: api.deleteOrder(merchantConfig, orderId).handle( { _ -> assertUiThread() mIsLoading.value = false mForceDeleteOrderId.value = orderId // <- every failure } The backend does distinguish these cases: 404 for an unknown order, and 409 for already-paid, awaiting-payment (that is the claim) and externally-paid. Only a 409 is a refusal that `force` can override. The correct pattern already exists twenty lines above the second one. HistoryManager.deleteConfirmedOrder() asks first and derives force from the answer: api.checkOrder(config, item.orderId).handle({ error = it }) { current = it } ... val force = checked is CheckPaymentResponse.Claimed || (checked.paymentDetails?.amountExternal?.isNotEmpty() ?: item.hasExternalPayments) CONSEQUENCES BEYOND THE WRONG SENTENCE Pressing **Force delete** afterwards does not help: PaymentManager.forceDeleteOrder sends the same request over the same broken transport, reports failure only to Log.w, and ProcessPaymentFragment navigates back unconditionally -- so the cashier sees no error and the order survives. Pressing **Cancel payment** on the dialog drops them back on the payment screen with no message at all. SUGGESTED FIX (patch attached, verified) Give `Response` the status code it already has but does not expose, and raise the force prompt only on 409: val statusCode: Int? get() = ((value as? Failure)?.exception as? ResponseException) ?.response?.status?.value ... val needsForce = result.statusCode == 409 with the non-409 branch surfacing the real error instead (the payment screen through `mPayment.error`, the history list through the existing error_delete_order string). Verified on an emulator: the same sequence that produced "This order has been claimed by a wallet" now produces Error: No payment received | Network error: check your internet connection and merchant URL. | OK while the backend still reports that order `unpaid` -- which is the truth. :merchant-terminal:testSimUnitTest and :merchant-lib:test pass with the patch. Two refinements deliberately left out of the patch, as they want a decision rather than a fix: 409/2521 (ALREADY_PAID) and 409/2537 (EXTERNALLY_PAID) also reach the "claimed by a wallet" wording, so the message could be split by error code; and forceDeleteOrder should report its own failure rather than navigating back on hope. I am a reporter on this tracker and cannot triage or assign. The patch is attached as force-delete-blames-a-wallet.diff and applies to merchant-lib/src/main/java/net/taler/merchantlib/Response.kt, merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt and merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt at 58b2417d. | ||||
| Steps To Reproduce | The defect is that the reason for the failure is never checked, so any way of making DELETE /private/orders/<id> fail will show it. Two: A. Without any tooling 1. Provision the terminal against a merchant backend. 2. Order by amount, enter an amount, press Charge. 3. Put the device in airplane mode (or stop the backend). 4. Cancel the order. B. As the simulator does it 1. Put taler-twister in front of the backend and arm a mutation that makes the pay URI insecure (taler+http://). 2. Charge an order; the terminal offers "Try fixed link / Continue as is / Cancel order". 3. Choose "Cancel order". Incidentally: taler-twister proxies GET, POST and PUT only and closes the connection on DELETE, which is what makes this path fail there. Observed in both: "Force delete order? / This order has been claimed by a wallet. ..." The backend, asked directly at the same moment, reports the order order_status='unpaid', and it is still there afterwards. Expected: the real reason -- a network error here -- and the force-delete prompt only when the backend actually refused with 409. | ||||
| Additional Information | taler-android master at 58b2417d (2026-11-08). The string and the over-broad mapping arrived together in ae24e23b (2026-06-07), so the sentence was written for the claimed case and the code was never narrowed to it; this predates the mixed-payment work. Found by the pos-simulator harness in the same tree (scenario fault_insecure_pay_uri_cancel), reproduced three times. Independent of screen size. | ||||
| Tags | No tags attached. | ||||
| Attached Files | force-delete-blames-a-wallet.diff (4,157 bytes)
diff --git a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
index b91e0723..c49eb345 100644
--- a/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
+++ b/merchant-lib/src/main/java/net/taler/merchantlib/Response.kt
@@ -56,6 +56,15 @@ class Response<out T> private constructor(
val isFailure: Boolean get() = value is Failure
+ /**
+ * The HTTP status the backend answered with, or null when the request never
+ * got an answer at all (a network error) or succeeded. Callers that have to
+ * tell one kind of failure from another need this: the message cannot.
+ */
+ val statusCode: Int?
+ get() = ((value as? Failure)?.exception as? ResponseException)
+ ?.response?.status?.value
+
suspend fun handle(onFailure: ((String) -> Unit)? = null, onSuccess: ((T) -> Unit)? = null) {
if (value is Failure) onFailure?.let { it(getFailureString(value)) }
else onSuccess?.let {
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
index 1b8e27a2..41ac8c44 100644
--- a/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/history/HistoryManager.kt
@@ -233,11 +233,22 @@ class HistoryManager(
mIsLoading.value = true
mIsLoadingMore.value = false
val merchantConfig = configManager.merchantConfig!!
- api.deleteOrder(merchantConfig, orderId).handle(
- { _ ->
+ val result = api.deleteOrder(merchantConfig, orderId)
+ // Same as on the payment screen: only a 409 is a claim that force can
+ // override, so only a 409 may raise the force-delete prompt.
+ val needsForce = result.statusCode == 409
+ result.handle(
+ { errorMsg ->
assertUiThread()
mIsLoading.value = false
- mForceDeleteOrderId.value = orderId
+ if (needsForce)
+ {
+ mForceDeleteOrderId.value = orderId
+ }
+ else
+ {
+ onError(R.string.error_delete_order, errorMsg)
+ }
}
) {
assertUiThread()
diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
index 91672e4f..9934d9c5 100644
--- a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
+++ b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/PaymentManager.kt
@@ -402,10 +402,23 @@ class PaymentManager(
val orderId = mPayment.value?.orderId ?: return
scope.launch {
val result = api.deleteOrder(merchantConfig, orderId)
+ // Only a 409 is the backend refusing because the order is claimed or
+ // already paid. Every other failure -- no network, a 5xx, an expired
+ // token -- is a failure of the request itself, which force cannot fix
+ // and which must not be reported to the cashier as a wallet's claim.
+ val needsForce = result.statusCode == 409
result.handle(
onFailure = { errorStr ->
Log.w(TAG, "Delete order $orderId failed: $errorStr")
- mDeleteNeedsForce.postValue(true)
+ if (needsForce)
+ {
+ mDeleteNeedsForce.postValue(true)
+ }
+ else
+ {
+ mPayment.postValue(mPayment.value?.copy(error = errorStr))
+ mDeleteNeedsForce.postValue(null)
+ }
},
onSuccess = {
Log.d(TAG, "Delete order $orderId succeeded")
| ||||