View Issue Details

IDProjectCategoryView StatusLast Update
0012097Talerwallet (Android App)public2026-11-11 01: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 
Summary0012097: Android: payment screen shows a product's line total multiplied by its quantity again ("2 x 3.00" for a line costing 3.00, lines don't add up to the total) and ignores product_name (patch attached)
DescriptionAndroid's payment screen multiplies each product's price by its quantity again: for a line "2 x Espresso" that costs KUDOS 3.00 in total it shows "2 x 3.00", so the product lines no longer add up to the total that is charged. It also ignores product_name and shows only the description.

The contract field ProductSold.price is the total for quantity times unit of the product (taler-docs core/api-merchant.rst:2326-2330; taler-util types-taler-merchant.ts:4249-4251), and prices (v25) is the price of unit_quantity units. The merchant computes it that way itself for inventory products (taler-merchant-httpd_post-private-orders.c:3022-3045, TALER_MERCHANT_amount_multiply_by_quantity) -- which is how the Android PoS sells catalogue items (merchant-terminal payment/OrderRequest.kt:40-68 posts inventory_products) -- and the merchant web UI sends prices = unit price x quantity for one-off items (CreateOrderScreen.tsx:1049-1058).

What the user sees (origin/master builds of 2026-11-11). Order: Espresso, quantity 2, line total KUDOS:3; Croissant, quantity 1, KUDOS:0.50; total KUDOS:3.50. The signed contract (from taler-wallet-cli) carries {"product_name":"Espresso","description":"Double espresso","quantity":2,"unit_quantity":"2","price":"KUDOS:3","prices":["KUDOS:3"]}.
- Android: "Double espresso" with "2 x 3.00", "Butter croissant" with "0.50", then "Total: 3.50" -- the lines suggest 6.50 (before-android-1.png). No product names.
- iOS: "Espresso" / "Double espresso" / "2" with "KUDOS 3.00"; "Croissant" / "Butter croissant" / "1" with "KUDOS 0.50" (before-ios-1.png).
- WebExtension: "Espresso" / "2 items" / "Double espresso" with "KUDOS:3"; "Croissant" / "1 item" / "Butter croissant" with "KUDOS:0.5" (before-web-1.png).

Code (origin/master 4c483527):
- wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt:377-407 (ProductItem): the name is product.localizedDescription only; the price line is "%1$s x %2$s" (quantity, price) whenever quantity > 1.
- taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt:193-214 (ContractProduct): unit_quantity and unit are not decoded; totalPrice = price * quantity (unused by the wallet).
References: iOS TalerWallet1/Views/Sheets/Payment/ChoicesView.swift:178-220; web routes/order-model.ts:16-75 and ui/OrderSummary.tsx:59-81.

Patch (android-product-line-total.patch):
- ContractProduct decodes unit_quantity and unit (fractional quantities like "0.5" were shown as nothing before, since the legacy integer quantity is only sent for whole numbers).
- ProductItem shows product_name (falling back to the description) with the quantity in front of it ("2 x Espresso", reusing the existing string payment_product_price_quantity), the description below it when it differs from the name, and the price as given, i.e. the line total.
No new strings.
Verified: :wallet:assembleFdroidSim builds, :taler-kotlin-android:testDebugUnitTest passes, :merchant-terminal:compileDebugKotlin still compiles; after-android-1.png is the same order with the patch ("2 x Espresso / Double espresso / 3.00", "Croissant / Butter croissant / 0.50", total 3.50).

Assisted by LLM
Steps To Reproduce1. Create an order with a product of quantity 2 whose price (line total) is 3, e.g. products: [{"product_name":"Espresso","description":"Double espresso","unit_quantity":"2","prices":["KUDOS:3"]}], or sell 2 of an inventory product from the PoS.
2. Open its taler://pay/ link: Android shows "2 x 3.00" for a line that costs 3.00.
(Automated: wc-stack pay-uri --order-json f210-order.json, attached.)
TagsNo tags attached.
Attached Files
before-android-1.png (83,431 bytes)   
before-android-1.png (83,431 bytes)   
before-ios-1.png (222,339 bytes)   
before-ios-1.png (222,339 bytes)   
before-web-1.png (107,845 bytes)   
before-web-1.png (107,845 bytes)   
after-android-1.png (90,168 bytes)   
after-android-1.png (90,168 bytes)   
android-product-line-total.patch (3,655 bytes)   
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt
index 1f59d332..26a2cf4d 100644
--- a/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt
+++ b/taler-kotlin-android/src/main/java/net/taler/common/ContractTerms.kt
@@ -207,6 +207,13 @@ data class ContractProduct(
     override val image: String? = null,
     override val taxes: Set<Tax>? = null,
     val quantity: Int = 1,
+    /**
+     * Quantity as a decimal string (since merchant protocol v25), preferred over
+     * [quantity], which is only the legacy integer part.
+     */
+    @SerialName("unit_quantity")
+    val unitQuantity: String? = null,
+    val unit: String? = null,
 ) : OrderProduct() {
     val totalPrice: Amount? by lazy {
         price?.let { price * quantity }
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt
index 3663d1d3..6870388f 100644
--- a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt
+++ b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentComposable.kt
@@ -379,25 +379,38 @@ fun ProductItem(
                 Modifier.fillMaxWidth(),
                 horizontalAlignment = Alignment.End,
             ) {
-                // NAME
+                // NAME, prefixed with the quantity unless it is a single item
+                val name = product.productName?.takeIf { it.isNotBlank() }
+                    ?: product.localizedDescription
+                val quantity = (product.unitQuantity ?: product.quantity.toString()).let { q ->
+                    product.unit?.takeIf { it.isNotBlank() }?.let { "$q $it" } ?: q
+                }
                 Text(
-                    product.localizedDescription,
+                    if (quantity != "1") {
+                        stringResource(R.string.payment_product_price_quantity, quantity, name)
+                    } else {
+                        name
+                    },
                     modifier = Modifier.fillMaxWidth(),
                     style = MaterialTheme.typography.bodyLarge,
                 )
 
-                // PRICE
+                // DESCRIPTION, if the name did not already show it
+                if (name != product.localizedDescription) {
+                    Text(
+                        product.localizedDescription,
+                        modifier = Modifier.fillMaxWidth(),
+                        style = MaterialTheme.typography.bodyMedium.copy(
+                            color = ListItemDefaults.colors().supportingTextColor,
+                        ),
+                    )
+                }
+
+                // PRICE: the merchant already gives the total for this line
+                // (price of the quantity), it must not be multiplied again
                 product.price?.let { price ->
                     Text(
-                        if (product.quantity > 1) {
-                            stringResource(
-                                R.string.payment_product_price_quantity,
-                                product.quantity.toString(),
-                                price.toString(),
-                            )
-                        } else {
-                            price.toString()
-                        },
+                        price.toString(),
                         style = MaterialTheme.typography.bodyMedium.copy(
                             color = ListItemDefaults.colors().supportingTextColor,
                         ),
f210-order.json (389 bytes)   
{
  "summary": "F210 two espressos and a croissant",
  "amount": "KUDOS:3.50",
  "products": [
    {"product_id": "f210-1", "product_name": "Espresso", "description": "Double espresso",
     "unit_quantity": "2", "prices": ["KUDOS:3"]},
    {"product_id": "f210-2", "product_name": "Croissant", "description": "Butter croissant",
     "unit_quantity": "1", "prices": ["KUDOS:0.50"]}
  ]
}
f210-order.json (389 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-11 01:55bot-cgNew Issue
2026-11-11 01:55bot-cgFile Added: before-android-1.png
2026-11-11 01:55bot-cgFile Added: before-ios-1.png
2026-11-11 01:55bot-cgFile Added: before-web-1.png
2026-11-11 01:55bot-cgFile Added: after-android-1.png
2026-11-11 01:55bot-cgFile Added: android-product-line-total.patch
2026-11-11 01:55bot-cgFile Added: f210-order.json