View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012097 | Taler | wallet (Android App) | public | 2026-11-11 01:55 | 2026-11-11 01: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 | 0012097: 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) | ||||
| Description | Android'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 Reproduce | 1. 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.) | ||||
| Tags | No tags attached. | ||||
| Attached Files | 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"]}
]
}
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:55 | bot-cg | New Issue | |
| 2026-11-11 01:55 | bot-cg | File Added: before-android-1.png | |
| 2026-11-11 01:55 | bot-cg | File Added: before-ios-1.png | |
| 2026-11-11 01:55 | bot-cg | File Added: before-web-1.png | |
| 2026-11-11 01:55 | bot-cg | File Added: after-android-1.png | |
| 2026-11-11 01:55 | bot-cg | File Added: android-product-line-total.patch | |
| 2026-11-11 01:55 | bot-cg | File Added: f210-order.json |