PaymentManager.kt
/*
* This file is part of GNU Taler
* (C) 2020 Taler Systems S.A.
*
* GNU Taler is free software; you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3, or (at your option) any later version.
*
* GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
package net.taler.wallet.payment
import android.util.Log
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
import net.taler.common.ContractInput
import net.taler.common.ContractOutput
import net.taler.common.ContractTerms
import net.taler.common.CurrencySpecification
import net.taler.common.TalerUtils.getLocalizedString
import net.taler.common.withSpec
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
import net.taler.wallet.balances.ScopeInfo
import net.taler.wallet.donau.DonauInfo
import net.taler.wallet.donau.GetDonauResponse
import net.taler.wallet.exchanges.ExchangeManager
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
import net.taler.wallet.transactions.TransactionState
sealed class PayStatus(
open val transactionId: String? = null,
) {
data object None : PayStatus()
data object Loading : PayStatus()
data class Prepared(
override val transactionId: String,
) : PayStatus(transactionId)
data class Choices(
override val transactionId: String,
val contractTerms: ContractTerms,
val choices: List<PayChoiceDetails>,
val defaultChoiceIndex: Int? = null,
) : PayStatus(transactionId)
data class Checked(
val details: WalletTemplateDetails,
val supportedCurrencies: List<String>,
) : PayStatus()
data class InsufficientBalance(
override val transactionId: String,
val contractTerms: ContractTerms,
val amountRaw: Amount,
val balanceDetails: PaymentInsufficientBalanceDetails,
) : PayStatus(transactionId)
data class AlreadyPaid(
override val transactionId: String,
) : PayStatus(transactionId)
data class Pending(
override val transactionId: String? = null,
val error: TalerErrorInfo? = null,
) : PayStatus(transactionId)
data class Error(
val error: TalerErrorInfo,
override val transactionId: String? = null,
) : PayStatus(transactionId)
data class Success(
override val transactionId: String,
val automaticExecution: Boolean,
) : 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,
val description: String? = null,
val descriptionI18n: Map<String, String>? = null,
val inputs: List<ContractInput>,
val outputs: List<ContractOutput>,
val details: ChoiceSelectionDetail,
) {
val localizedDescription: String?
get() = description?.let {
getLocalizedString(descriptionI18n, it)
}
}
@Serializable
data class CheckPayTemplateResponse(
val templateDetails: WalletTemplateDetails,
val supportedCurrencies: List<String>,
)
class PaymentManager(
private val api: WalletBackendApi,
private val scope: CoroutineScope,
private val exchangeManager: ExchangeManager,
) {
private val mPayStatus = MutableLiveData<PayStatus>(PayStatus.None)
internal val payStatus: LiveData<PayStatus> = mPayStatus
/**
* 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(),
progressToken = url,
) {
put("talerPayUri", url)
}.onError {
handleError("preparePayForUriV2", it)
}.onSuccess { response ->
currentTransactionId = response.transactionId
transactionId = response.transactionId
}
return transactionId
}
fun waitForTransaction(transactionId: String, major: TransactionMajorState) {
scope.launch {
api.request(
"waitTransactionState",
WaitTransactionStateResponse.serializer(),
timeoutMs = null,
progressToken = transactionId,
) {
put("transactionId", transactionId)
put("txState", JSONObject(BackendManager.json.encodeToString(TransactionState(major))))
put("bailStates", "final")
}.onError {
Log.d(TAG, "waitTransactionState failed: $it")
}
}
}
fun cancelWaitForTransaction(transactionId: String) {
scope.launch {
api.cancelProgressToken("waitTransactionState", transactionId)
}
}
@UiThread
fun getPaymentChoices(
transactionId: String,
onSuccess: () -> Unit,
) {
currentTransactionId = transactionId
startNewAttempt {
// Fresh payments start at a loading prompt.
mPayStatus.value = PayStatus.Loading
when (val response = api.request(
"getChoicesForPayment",
GetChoicesForPaymentResponse.serializer(),
) {
put("transactionId", transactionId)
}) {
is WalletResponse.Error -> {
val error = response.error
if (error.code == TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED) {
// The proposal is not downloaded yet; keep loading until it is.
Log.d(TAG, "getChoicesForPayment not ready yet: $error")
} else {
cancelWaitForTransaction(transactionId)
handleError("getChoicesForPayment", error, transactionId)
}
}
is WalletResponse.Success -> {
val res = response.result
if (res.automaticExecution == true && res.automaticExecutableIndex != null) {
confirmPay(
transactionId,
res.automaticExecutableIndex,
automaticExecution = true
)
return@startNewAttempt
}
val contractSpecs = mutableMapOf<String, CurrencySpecification?>()
for (currency in res.contractTerms.amountCurrencies()) {
contractSpecs[currency] = res.contractTerms.specFor(currency)
}
val contractTerms = res.contractTerms.withSpec { currency ->
contractSpecs[currency]
}
mPayStatus.value = PayStatus.Choices(
transactionId = transactionId,
contractTerms = contractTerms,
defaultChoiceIndex = res.defaultChoiceIndex,
choices = res.choices.map { choice ->
val spec = contractSpecs[choice.amountRaw.currency]
?: contractTerms.specFor(choice.amountRaw.currency)
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 = (contractTerms as? ContractTerms.V1)
?.choices?.get(i)?.inputs ?: listOf(),
outputs = (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()
},
),
)
cancelWaitForTransaction(transactionId)
onSuccess()
}
}
}
}
fun confirmPay(
transactionId: String,
choiceIndex: Int? = null,
automaticExecution: Boolean = false,
useDonau: Boolean = false,
) {
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 ->
val id = when (response) {
is ConfirmPayResult.Done -> response.transactionId
is ConfirmPayResult.Pending -> response.transactionId
}
waitForTransaction(id, TransactionMajorState.Done)
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,
)
})
}
}
}
suspend fun checkDonauForChoice(
choiceDetails: PayChoiceDetails
): DonauStatus {
val taxReceipt = (choiceDetails.outputs)
.find { it is ContractOutput.TaxReceipt }
as ContractOutput.TaxReceipt?
return if (taxReceipt != null) {
var donauInfo: DonauInfo? = null
api.request("getDonau", GetDonauResponse.serializer())
.onSuccess { donauInfo = it.currentDonauInfo }
if (donauInfo == null) {
DonauStatus.Unset(taxReceipt.donauUrls.distinct())
} else if (taxReceipt.donauUrls.contains(donauInfo!!.donauBaseUrl)) {
DonauStatus.Available
} else {
DonauStatus.Mismatch(
donauInfo = donauInfo!!,
donauUrls = taxReceipt.donauUrls.distinct(),
)
}
} else {
DonauStatus.Unavailable
}
}
fun checkPayForTemplate(url: String) {
currentTransactionId = null
mPayStatus.value = PayStatus.Loading
startNewAttempt {
when (val response = api.request(
"checkPayForTemplate",
CheckPayTemplateResponse.serializer(),
progressToken = url,
) {
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,
)
}
}
}
}
}
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(),
progressToken = url,
) {
put("talerPayTemplateUri", url)
put("templateParams", JSONObject(BackendManager.json.encodeToString(params)))
}.onError {
handleError("preparePayForTemplate", it)
}.onSuccess { response ->
currentTransactionId = response.transactionId
transactionId = response.transactionId
waitForTransaction(response.transactionId, TransactionMajorState.Dialog)
}
return transactionId
}
/**
* Called on transaction-state-transition notifications. If the currently
* 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) {
if (!payload.isClaimFailureDeletion()) return
val transactionId = checkNotNull(payload.transactionId)
if (transactionId != currentTransactionId) return
currentTransactionId = null
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, transactionId = transactionId))
}
private suspend fun ContractTerms.specFor(currency: String): CurrencySpecification? {
val scopes = exchanges.map { ScopeInfo.Exchange(currency, it.url) }
return exchangeManager.getSpecForCurrency(currency, scopes)
?: exchangeManager.getSpecForScopeInfo(ScopeInfo.Global(currency))
}
private fun ContractTerms.amountCurrencies(): Set<String> = buildSet {
when (this@amountCurrencies) {
is ContractTerms.V0 -> {
add(amount.currency)
add(maxFee.currency)
}
is ContractTerms.V1 -> choices.forEach {
add(it.amount.currency)
add(it.maxFee.currency)
}
}
products?.forEach { product ->
product.price?.let { add(it.currency) }
product.taxes?.forEach { add(it.tax.currency) }
}
if (this@amountCurrencies is ContractTerms.V1) {
choices.flatMap { it.outputs }
.filterIsInstance<ContractOutput.TaxReceipt>()
.forEach { add(it.amount.currency) }
}
}
private fun handleError(operation: String, error: TalerErrorInfo, transactionId: String? = null) {
Log.e(TAG, "got $operation error result $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