commit b5b77ef3b0aec393071e94843433eff49bd46495
parent 3276c8fb44f1203938f44c054c0332bb865e3744
Author: Iván Ávalos <avalos@disroot.org>
Date: Sat, 29 Aug 2026 00:07:37 +0200
[wallet] handle payment claim races and stale UI state
Diffstat:
6 files changed, 248 insertions(+), 127 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt b/wallet/src/main/java/net/taler/wallet/HandleUriScreen.kt
@@ -150,6 +150,11 @@ fun HandleUriScreen(
}
}
+ LaunchedEffect(payStatus) {
+ val error = (payStatus as? PayStatus.Error)?.error ?: return@LaunchedEffect
+ errorInfo = error
+ }
+
LaunchedEffect(errorInfo) {
val currentError = errorInfo
if (currentError != null) {
diff --git a/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt b/wallet/src/main/java/net/taler/wallet/backend/TalerErrorCode.kt
@@ -1718,6 +1718,9 @@ enum class TalerErrorCode(val code: Int) {
/** A parameter in the request is malformed or missing. */
WALLET_CORE_API_BAD_REQUEST(7048),
+ /** The order could not be found. Maybe the merchant deleted it. */
+ WALLET_MERCHANT_ORDER_NOT_FOUND(7049),
+
/** The HTTP server failed to allocate memory. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate. */
GENERIC_ALLOCATION_FAILURE(71),
diff --git a/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt b/wallet/src/main/java/net/taler/wallet/backend/WalletResponse.kt
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -72,6 +73,10 @@ data class TalerErrorInfo(
// Error extra details
val extra: Map<String, JsonElement> = mapOf(),
+
+ // Preserve codes that are newer than this app's error-code registry,
+ // so they are not re-encoded as -1.
+ val rawCode: Int = code.code,
) {
val userFacingMsg: String
get() {
@@ -119,6 +124,8 @@ class TalerErrorInfoSerializer : KSerializer<TalerErrorInfo> {
val json = decoder.json
val filtersMap = decoder.decodeSerializableValue(stringToJsonElementSerializer)
+ val rawCode = filtersMap["code"]?.jsonPrimitive?.intOrNull
+ ?: TalerErrorCode.UNKNOWN.code
val code = filtersMap["code"]?.let {
json.decodeFromJsonElement(TalerErrorCode.serializer(), it)
} ?: TalerErrorCode.UNKNOWN
@@ -132,12 +139,12 @@ class TalerErrorInfoSerializer : KSerializer<TalerErrorInfo> {
val knownKeys = setOf("code", "hint", "message")
val unknownFilters = filtersMap.filter { (key, _) -> !knownKeys.contains(key) }
- return TalerErrorInfo(code, hint, message, unknownFilters)
+ return TalerErrorInfo(code, hint, message, unknownFilters, rawCode)
}
override fun serialize(encoder: Encoder, value: TalerErrorInfo) {
encoder.encodeSerializableValue(JsonObject.serializer(), buildJsonObject {
- put("code", JsonPrimitive(value.code.code))
+ put("code", JsonPrimitive(value.rawCode))
put("hint", JsonPrimitive(value.hint))
put("message", JsonPrimitive(value.message))
value.extra.forEach { (key, value) -> put(key, value) }
diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt
@@ -21,6 +21,7 @@ import androidx.annotation.UiThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import net.taler.common.Amount
@@ -31,6 +32,7 @@ import net.taler.common.TalerUtils.getLocalizedString
import net.taler.wallet.main.TAG
import net.taler.wallet.backend.BackendManager
import net.taler.wallet.backend.NotificationPayload
+import net.taler.wallet.backend.TalerErrorCode
import net.taler.wallet.backend.TalerErrorInfo
import net.taler.wallet.backend.WalletBackendApi
import net.taler.wallet.backend.WalletResponse
@@ -42,20 +44,23 @@ import org.json.JSONObject
import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail
import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible
import net.taler.wallet.transactions.TransactionMajorState
+import net.taler.wallet.transactions.TransactionMinorState
-sealed class PayStatus {
+sealed class PayStatus(
+ open val transactionId: String? = null,
+) {
data object None : PayStatus()
data object Loading : PayStatus()
data class Prepared(
- val transactionId: String,
- ) : PayStatus()
+ override val transactionId: String,
+ ) : PayStatus(transactionId)
data class Choices(
- val transactionId: String,
+ override val transactionId: String,
val contractTerms: ContractTerms,
val choices: List<PayChoiceDetails>,
val defaultChoiceIndex: Int? = null,
- ) : PayStatus()
+ ) : PayStatus(transactionId)
data class Checked(
val details: WalletTemplateDetails,
@@ -63,31 +68,40 @@ sealed class PayStatus {
) : PayStatus()
data class InsufficientBalance(
- val transactionId: String,
+ override val transactionId: String,
val contractTerms: ContractTerms,
val amountRaw: Amount,
val balanceDetails: PaymentInsufficientBalanceDetails,
- ) : PayStatus()
+ ) : PayStatus(transactionId)
data class AlreadyPaid(
- val transactionId: String,
- ) : PayStatus()
+ override val transactionId: String,
+ ) : PayStatus(transactionId)
data class Pending(
- val transactionId: String? = null,
+ override val transactionId: String? = null,
val error: TalerErrorInfo? = null,
- ) : PayStatus()
+ ) : PayStatus(transactionId)
data class Error(
val error: TalerErrorInfo,
- ) : PayStatus()
+ override val transactionId: String? = null,
+ ) : PayStatus(transactionId)
data class Success(
- val transactionId: String,
+ override val transactionId: String,
val automaticExecution: Boolean,
- ) : PayStatus()
+ ) : PayStatus(transactionId)
}
+/**
+ * Payment status is shared between all payment attempts, so screens must
+ * scope the observed status to their own transaction: status produced by
+ * another transaction is never rendered, instead the screen keeps loading.
+ */
+internal fun PayStatus.forTransaction(transactionId: String): PayStatus =
+ if (this.transactionId == transactionId) this else PayStatus.Loading
+
data class PayChoiceDetails(
val choiceIndex: Int,
val amountRaw: Amount,
@@ -118,15 +132,40 @@ class PaymentManager(
internal val payStatus: LiveData<PayStatus> = mPayStatus
/**
- * Transaction id of the currently prepared payment. Watched for
+ * Transaction id of the currently active payment. Watched for
* transaction-state-transition notifications so that a transaction
* deleted by wallet-core (e.g. after a failed claim) surfaces an
* error in the UI instead of leaving the user on a loading screen.
+ * Cleared whenever a new payment attempt starts or the currently active
+ * payment completes, so failures of an older payment are never shown
+ * during a newer one.
*/
@Volatile
private var currentTransactionId: String? = null
+ /**
+ * In-flight request of the currently active payment attempt. A new
+ * payment attempt cancels it, so an error or late response from an
+ * older attempt can never appear during a newer payment.
+ */
+ private var attemptJob: Job? = null
+
+ private fun cancelPreviousAttempt() {
+ attemptJob?.cancel()
+ attemptJob = null
+ }
+
+ private fun startNewAttempt(block: suspend CoroutineScope.() -> Unit) {
+ cancelPreviousAttempt()
+ attemptJob = scope.launch { block() }
+ }
+
suspend fun preparePay(url: String): String? {
+ // Runs in the caller's scope, which is cancelled when the caller
+ // leaves the screen, so a response can never outlive this payment.
+ cancelPreviousAttempt()
+ currentTransactionId = null
+ mPayStatus.value = PayStatus.Loading
var transactionId: String? = null
api.request("preparePayForUriV2", PreparePayV2Response.serializer()) {
put("talerPayUri", url)
@@ -143,74 +182,77 @@ class PaymentManager(
fun getPaymentChoices(
transactionId: String,
onSuccess: () -> Unit,
- ) = scope.launch {
- when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) {
- put("transactionId", transactionId)
- }) {
- is WalletResponse.Error -> handleError("getChoicesForPayment", response.error)
-
- is WalletResponse.Success -> {
- val res = response.result
- if (res.automaticExecution == true && res.automaticExecutableIndex != null) {
- confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true)
- return@launch
- }
+ ) {
+ currentTransactionId = transactionId
+ startNewAttempt {
+ when (val response = api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) {
+ put("transactionId", transactionId)
+ }) {
+ is WalletResponse.Error -> handleError("getChoicesForPayment", response.error, transactionId)
- mPayStatus.value = PayStatus.Choices(
- transactionId = transactionId,
- contractTerms = res.contractTerms,
- defaultChoiceIndex = res.defaultChoiceIndex,
- choices = res.choices.map { choice ->
- val spec = exchangeManager.getSpecForCurrency(
- choice.amountRaw.currency,
- res.contractTerms.exchanges.map {
- ScopeInfo.Exchange(choice.amountRaw.currency, it.url)
- },
- ) ?: res.contractTerms.exchanges.firstOrNull()?.let {
- exchangeManager.getSpecForScopeInfo(
- ScopeInfo.Exchange(choice.amountRaw.currency, it.url)
- )
- }
+ is WalletResponse.Success -> {
+ val res = response.result
+ if (res.automaticExecution == true && res.automaticExecutableIndex != null) {
+ confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true)
+ return@startNewAttempt
+ }
- when (choice) {
- is PaymentPossible -> {
- choice.copy(
- amountRaw = choice.amountRaw.withSpec(spec),
- amountEffective = choice.amountEffective.withSpec(spec),
+ mPayStatus.value = PayStatus.Choices(
+ transactionId = transactionId,
+ contractTerms = res.contractTerms,
+ defaultChoiceIndex = res.defaultChoiceIndex,
+ choices = res.choices.map { choice ->
+ val spec = exchangeManager.getSpecForCurrency(
+ choice.amountRaw.currency,
+ res.contractTerms.exchanges.map {
+ ScopeInfo.Exchange(choice.amountRaw.currency, it.url)
+ },
+ ) ?: res.contractTerms.exchanges.firstOrNull()?.let {
+ exchangeManager.getSpecForScopeInfo(
+ ScopeInfo.Exchange(choice.amountRaw.currency, it.url)
)
}
- is ChoiceSelectionDetail.InsufficientBalance -> {
- choice.copy(amountRaw = choice.amountRaw.withSpec(spec))
+ when (choice) {
+ is PaymentPossible -> {
+ choice.copy(
+ amountRaw = choice.amountRaw.withSpec(spec),
+ amountEffective = choice.amountEffective.withSpec(spec),
+ )
+ }
+
+ is ChoiceSelectionDetail.InsufficientBalance -> {
+ choice.copy(amountRaw = choice.amountRaw.withSpec(spec))
+ }
}
- }
- }.mapIndexed { i, choice ->
- PayChoiceDetails(
- choiceIndex = i,
- description = choice.description,
- descriptionI18n = choice.descriptionI18n,
- amountRaw = choice.amountRaw,
- inputs = (res.contractTerms as? ContractTerms.V1)
- ?.choices?.get(i)?.inputs ?: listOf(),
- outputs = (res.contractTerms as? ContractTerms.V1)
- ?.choices?.get(i)?.outputs ?: listOf(),
- details = choice,
- )
- }.filter {
- // Hide auto executable choice
- res.automaticExecutableIndex != it.choiceIndex
- }.sortedWith(
- compareByDescending<PayChoiceDetails> {
- it.choiceIndex == res.defaultChoiceIndex
- }.thenByDescending {
- it.details is PaymentPossible
- }.thenByDescending {
- it.amountRaw.toString()
- },
- ),
- )
+ }.mapIndexed { i, choice ->
+ PayChoiceDetails(
+ choiceIndex = i,
+ description = choice.description,
+ descriptionI18n = choice.descriptionI18n,
+ amountRaw = choice.amountRaw,
+ inputs = (res.contractTerms as? ContractTerms.V1)
+ ?.choices?.get(i)?.inputs ?: listOf(),
+ outputs = (res.contractTerms as? ContractTerms.V1)
+ ?.choices?.get(i)?.outputs ?: listOf(),
+ details = choice,
+ )
+ }.filter {
+ // Hide auto executable choice
+ res.automaticExecutableIndex != it.choiceIndex
+ }.sortedWith(
+ compareByDescending<PayChoiceDetails> {
+ it.choiceIndex == res.defaultChoiceIndex
+ }.thenByDescending {
+ it.details is PaymentPossible
+ }.thenByDescending {
+ it.amountRaw.toString()
+ },
+ ),
+ )
- onSuccess()
+ onSuccess()
+ }
}
}
}
@@ -220,25 +262,31 @@ class PaymentManager(
choiceIndex: Int? = null,
automaticExecution: Boolean = false,
useDonau: Boolean = false,
- ) = scope.launch {
- mPayStatus.postValue(PayStatus.Loading)
- api.request("confirmPay", ConfirmPayResult.serializer()) {
- choiceIndex?.let { put("choiceIndex", it) }
- put("transactionId", transactionId)
- put("useDonau", useDonau)
- }.onError {
- handleError("confirmPay", it)
- }.onSuccess { response ->
- mPayStatus.postValue(when (response) {
- is ConfirmPayResult.Done -> PayStatus.Success(
- transactionId = response.transactionId,
- automaticExecution = automaticExecution,
- )
- is ConfirmPayResult.Pending -> PayStatus.Pending(
- transactionId = response.transactionId,
- error = response.lastError,
- )
- })
+ ) {
+ currentTransactionId = transactionId
+ startNewAttempt {
+ mPayStatus.postValue(PayStatus.Loading)
+ api.request("confirmPay", ConfirmPayResult.serializer()) {
+ choiceIndex?.let { put("choiceIndex", it) }
+ put("transactionId", transactionId)
+ put("useDonau", useDonau)
+ }.onError {
+ handleError("confirmPay", it, transactionId)
+ }.onSuccess { response ->
+ mPayStatus.postValue(when (response) {
+ is ConfirmPayResult.Done -> {
+ currentTransactionId = null
+ PayStatus.Success(
+ transactionId = response.transactionId,
+ automaticExecution = automaticExecution,
+ )
+ }
+ is ConfirmPayResult.Pending -> PayStatus.Pending(
+ transactionId = response.transactionId,
+ error = response.lastError,
+ )
+ })
+ }
}
}
@@ -269,29 +317,41 @@ class PaymentManager(
}
}
- fun checkPayForTemplate(url: String) = scope.launch {
+ fun checkPayForTemplate(url: String) {
+ currentTransactionId = null
mPayStatus.value = PayStatus.Loading
- api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) {
- put("talerPayTemplateUri", url)
- }.onError {
- handleError("checkPayForTemplate", it)
- }.onSuccess { response ->
- if (response.templateDetails.templateContract.templateType == TemplateType.Paivana) {
- scope.launch {
- preparePayForTemplate(url, TemplateParams())?.let { transactionId ->
- mPayStatus.value = PayStatus.Prepared(transactionId = transactionId)
+ startNewAttempt {
+ when (val response = api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) {
+ put("talerPayTemplateUri", url)
+ }) {
+ is WalletResponse.Error -> handleError("checkPayForTemplate", response.error)
+
+ is WalletResponse.Success -> {
+ // The auto-prepare runs in this attempt, so it is
+ // cancelled together with it when a new attempt starts.
+ val res = response.result
+ if (res.templateDetails.templateContract.templateType == TemplateType.Paivana) {
+ mPayStatus.value = PayStatus.Loading
+ preparePayForTemplate(url, TemplateParams())?.let { transactionId ->
+ mPayStatus.value = PayStatus.Prepared(transactionId = transactionId)
+ }
+ } else {
+ mPayStatus.value = PayStatus.Checked(
+ details = res.templateDetails,
+ supportedCurrencies = res.supportedCurrencies,
+ )
}
}
- } else {
- mPayStatus.value = PayStatus.Checked(
- details = response.templateDetails,
- supportedCurrencies = response.supportedCurrencies,
- )
}
}
}
suspend fun preparePayForTemplate(url: String, params: TemplateParams): String? {
+ // Runs in the caller's (attempt or screen) scope; a newer payment
+ // attempt cancels the enclosing attempt scope before it can produce
+ // a stale result. The screen's check already superseded any earlier
+ // attempt before a form can be submitted.
+ mPayStatus.value = PayStatus.Loading
var transactionId: String? = null
api.request("preparePayForTemplateV2", PreparePayV2Response.serializer()) {
put("talerPayTemplateUri", url)
@@ -307,27 +367,40 @@ class PaymentManager(
/**
* Called on transaction-state-transition notifications. If the currently
- * prepared transaction is deleted by wallet-core (e.g. claim failed), the
- * UI is taken out of its loading state and shown the error instead.
+ * active payment is deleted by wallet-core with cause "claim-failed"
+ * (e.g. the merchant rejected or deleted the order), the UI is taken
+ * out of its loading state and shown the error instead.
*/
+ @Synchronized
fun onTransactionStateTransition(payload: NotificationPayload.TransactionStateTransition) {
- val transactionId = payload.transactionId
- if (transactionId == null || transactionId != currentTransactionId) return
- val newTxState = payload.newTxState ?: return
- if (newTxState.major != TransactionMajorState.Deleted) return
+ if (!payload.isClaimFailureDeletion()) return
+ val transactionId = checkNotNull(payload.transactionId)
+ if (transactionId != currentTransactionId) return
currentTransactionId = null
- val errorInfo = payload.errorInfo
- if (errorInfo == null) {
- Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core without error info")
- return
- }
+ val errorInfo = payload.errorInfo ?: TalerErrorInfo.makeCustomError(
+ message = "The order could not be found. Maybe the merchant deleted it.",
+ code = TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND,
+ )
Log.e(TAG, "prepared transaction $transactionId deleted by wallet-core: $errorInfo")
- mPayStatus.postValue(PayStatus.Error(error = errorInfo))
+ mPayStatus.postValue(PayStatus.Error(error = errorInfo, transactionId = transactionId))
}
- private fun handleError(operation: String, error: TalerErrorInfo) {
+ private fun handleError(operation: String, error: TalerErrorInfo, transactionId: String? = null) {
Log.e(TAG, "got $operation error result $error")
- mPayStatus.postValue(PayStatus.Error(error = error))
+ mPayStatus.postValue(PayStatus.Error(error = error, transactionId = transactionId))
}
}
+
+/**
+ * Matches the deletion notification emitted by wallet-core when a payment
+ * claim failed, e.g. because the merchant rejected or deleted the order.
+ * Only this specific state transition is of interest to in-progress
+ * payments; other deletions must not surface as payment errors.
+ */
+internal fun NotificationPayload.TransactionStateTransition.isClaimFailureDeletion(): Boolean =
+ transactionId != null &&
+ causeHint == "claim-failed" &&
+ oldTxState?.major == TransactionMajorState.Pending &&
+ oldTxState.minor == TransactionMinorState.ClaimProposal &&
+ newTxState?.major == TransactionMajorState.Deleted
diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt
@@ -92,7 +92,9 @@ fun TransactionPaymentComposable(
)
) {
return TransactionPaymentPrompt(
- payStatus = payStatus,
+ // Scope the shared payment status to this transaction, so an
+ // error or late response from another payment is never shown.
+ payStatus = payStatus.forTransaction(t.transactionId),
devMode = devMode,
modifier = modifier,
onConfirmPay = onConfirmPay,
diff --git a/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt b/wallet/src/test/java/net/taler/wallet/backend/WalletResponseTest.kt
@@ -17,6 +17,9 @@
package net.taler.wallet.backend
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import net.taler.wallet.balances.BalanceResponse
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -76,4 +79,32 @@ class WalletResponseTest {
val info = json.decodeFromString(TalerErrorInfo.serializer(), infoJson)
println(info.userFacingMsg)
}
+
+ @Test
+ fun knownWalletErrorCodeRoundTrips() {
+ val info = json.decodeFromString(
+ TalerErrorInfo.serializer(),
+ """{"code":7049}""",
+ )
+
+ assertEquals(TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND, info.code)
+ assertEquals(7049, info.rawCode)
+ assertEquals(7049, encodedCode(info))
+ }
+
+ @Test
+ fun futureWalletErrorCodeRoundTripsWithoutBecomingMinusOne() {
+ val info = json.decodeFromString(
+ TalerErrorInfo.serializer(),
+ """{"code":7998}""",
+ )
+
+ assertEquals(TalerErrorCode.UNKNOWN, info.code)
+ assertEquals(7998, info.rawCode)
+ assertEquals(7998, encodedCode(info))
+ }
+
+ private fun encodedCode(info: TalerErrorInfo): Int = json
+ .parseToJsonElement(json.encodeToString(TalerErrorInfo.serializer(), info))
+ .jsonObject.getValue("code").jsonPrimitive.int
}