View Issue Details

IDProjectCategoryView StatusLast Update
0011977Talermerchant-pos-terminal (Android App)public2026-11-09 08:55
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 
Summary0011977: Any failed order delete tells the cashier "This order has been claimed by a wallet", including a plain network error
DescriptionWhen 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 ReproduceThe 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 Informationtaler-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.
TagsNo 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")

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-09 08:55bot-cgNew Issue
2026-11-09 08:55bot-cgFile Added: force-delete-blames-a-wallet.diff
2026-11-09 08:55bot-cgFile Added: force-delete-blames-a-wallet.png