PaymentManager.kt (11793B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.wallet.payment 18 19 import android.util.Log 20 import androidx.annotation.UiThread 21 import androidx.lifecycle.LiveData 22 import androidx.lifecycle.MutableLiveData 23 import kotlinx.coroutines.CoroutineScope 24 import kotlinx.coroutines.launch 25 import kotlinx.serialization.Serializable 26 import net.taler.common.Amount 27 import net.taler.common.ContractInput 28 import net.taler.common.ContractOutput 29 import net.taler.common.ContractTerms 30 import net.taler.common.TalerUtils.getLocalizedString 31 import net.taler.wallet.main.TAG 32 import net.taler.wallet.backend.BackendManager 33 import net.taler.wallet.backend.TalerErrorInfo 34 import net.taler.wallet.backend.WalletBackendApi 35 import net.taler.wallet.balances.ScopeInfo 36 import net.taler.wallet.donau.DonauInfo 37 import net.taler.wallet.donau.GetDonauResponse 38 import net.taler.wallet.exchanges.ExchangeManager 39 import net.taler.wallet.payment.PayStatus.AlreadyPaid 40 import net.taler.wallet.payment.PayStatus.InsufficientBalance 41 import net.taler.wallet.payment.PreparePayResponse.AlreadyConfirmedResponse 42 import net.taler.wallet.payment.PreparePayResponse.InsufficientBalanceResponse 43 import net.taler.wallet.payment.PreparePayResponse.PaymentPossibleResponse 44 import org.json.JSONObject 45 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail 46 import net.taler.wallet.payment.GetChoicesForPaymentResponse.ChoiceSelectionDetail.PaymentPossible 47 48 sealed class PayStatus { 49 data object None : PayStatus() 50 data object Loading : PayStatus() 51 data class Prepared( 52 val transactionId: String, 53 val contractTerms: ContractTerms, 54 ) : PayStatus() 55 56 data class Choices( 57 val transactionId: String, 58 val contractTerms: ContractTerms, 59 val choices: List<PayChoiceDetails>, 60 val defaultChoiceIndex: Int? = null, 61 ) : PayStatus() 62 63 data class Checked( 64 val details: WalletTemplateDetails, 65 val supportedCurrencies: List<String>, 66 ) : PayStatus() 67 68 data class InsufficientBalance( 69 val transactionId: String, 70 val contractTerms: ContractTerms, 71 val amountRaw: Amount, 72 val balanceDetails: PaymentInsufficientBalanceDetails, 73 ) : PayStatus() 74 75 data class AlreadyPaid( 76 val transactionId: String, 77 ) : PayStatus() 78 79 data class Pending( 80 val transactionId: String? = null, 81 val error: TalerErrorInfo? = null, 82 ) : PayStatus() 83 data class Success( 84 val transactionId: String, 85 val automaticExecution: Boolean, 86 ) : PayStatus() 87 } 88 89 data class PayChoiceDetails( 90 val choiceIndex: Int, 91 val amountRaw: Amount, 92 val description: String? = null, 93 val descriptionI18n: Map<String, String>? = null, 94 val inputs: List<ContractInput>, 95 val outputs: List<ContractOutput>, 96 val details: ChoiceSelectionDetail, 97 ) { 98 val localizedDescription: String? 99 get() = description?.let { 100 getLocalizedString(descriptionI18n, it) 101 } 102 } 103 104 @Serializable 105 data class CheckPayTemplateResponse( 106 val templateDetails: WalletTemplateDetails, 107 val supportedCurrencies: List<String>, 108 ) 109 110 class PaymentManager( 111 private val api: WalletBackendApi, 112 private val scope: CoroutineScope, 113 private val exchangeManager: ExchangeManager, 114 ) { 115 private val mPayStatus = MutableLiveData<PayStatus>(PayStatus.None) 116 internal val payStatus: LiveData<PayStatus> = mPayStatus 117 118 suspend fun preparePay(url: String): String? { 119 var transactionId: String? = null 120 mPayStatus.value = PayStatus.Loading 121 api.request("preparePayForUriV2", PreparePayV2Response.serializer()) { 122 put("talerPayUri", url) 123 }.onError { 124 handleError("preparePayForUriV2", it) 125 }.onSuccess { response -> 126 transactionId = response.transactionId 127 } 128 return transactionId 129 } 130 131 @UiThread 132 fun getPaymentChoices( 133 transactionId: String, 134 onSuccess: () -> Unit, 135 ) = scope.launch { 136 api.request("getChoicesForPayment", GetChoicesForPaymentResponse.serializer()) { 137 put("transactionId", transactionId) 138 }.onSuccess { res -> 139 if (res.automaticExecution == true && res.automaticExecutableIndex != null) { 140 confirmPay(transactionId, res.automaticExecutableIndex, automaticExecution = true) 141 return@onSuccess 142 } 143 144 mPayStatus.value = PayStatus.Choices( 145 transactionId = transactionId, 146 contractTerms = res.contractTerms, 147 defaultChoiceIndex = res.defaultChoiceIndex, 148 choices = res.choices.map { choice -> 149 val spec = exchangeManager.getSpecForCurrency( 150 choice.amountRaw.currency, 151 res.contractTerms.exchanges.map { 152 ScopeInfo.Exchange(choice.amountRaw.currency, it.url) 153 }, 154 ) ?: exchangeManager.getSpecForCurrency(choice.amountRaw.currency) 155 156 when (choice) { 157 is PaymentPossible -> { 158 choice.copy( 159 amountRaw = choice.amountRaw.withSpec(spec), 160 amountEffective = choice.amountEffective.withSpec(spec), 161 ) 162 } 163 164 is ChoiceSelectionDetail.InsufficientBalance -> { 165 choice.copy(amountRaw = choice.amountRaw.withSpec(spec)) 166 } 167 } 168 }.mapIndexed { i, choice -> 169 PayChoiceDetails( 170 choiceIndex = i, 171 description = choice.description, 172 descriptionI18n = choice.descriptionI18n, 173 amountRaw = choice.amountRaw, 174 inputs = (res.contractTerms as? ContractTerms.V1) 175 ?.choices?.get(i)?.inputs ?: listOf(), 176 outputs = (res.contractTerms as? ContractTerms.V1) 177 ?.choices?.get(i)?.outputs ?: listOf(), 178 details = choice, 179 ) 180 }.filter { 181 // Hide auto executable choice 182 res.automaticExecutableIndex != it.choiceIndex 183 }.sortedWith( 184 compareByDescending<PayChoiceDetails> { 185 it.choiceIndex == res.defaultChoiceIndex 186 }.thenByDescending { 187 it.details is PaymentPossible 188 }.thenByDescending { 189 it.amountRaw.toString() 190 }, 191 ), 192 ) 193 194 onSuccess() 195 }.onError { error -> 196 handleError("getChoicesForPayment", error) 197 } 198 } 199 200 fun confirmPay( 201 transactionId: String, 202 choiceIndex: Int? = null, 203 automaticExecution: Boolean = false, 204 useDonau: Boolean = false, 205 ) = scope.launch { 206 mPayStatus.postValue(PayStatus.Loading) 207 api.request("confirmPay", ConfirmPayResult.serializer()) { 208 choiceIndex?.let { put("choiceIndex", it) } 209 put("transactionId", transactionId) 210 put("useDonau", useDonau) 211 }.onError { 212 handleError("confirmPay", it) 213 }.onSuccess { response -> 214 mPayStatus.postValue(when (response) { 215 is ConfirmPayResult.Done -> PayStatus.Success( 216 transactionId = response.transactionId, 217 automaticExecution = automaticExecution, 218 ) 219 is ConfirmPayResult.Pending -> PayStatus.Pending( 220 transactionId = response.transactionId, 221 error = response.lastError, 222 ) 223 }) 224 } 225 } 226 227 suspend fun checkDonauForChoice( 228 choiceDetails: PayChoiceDetails 229 ): DonauStatus { 230 val taxReceipt = (choiceDetails.outputs) 231 .find { it is ContractOutput.TaxReceipt } 232 as ContractOutput.TaxReceipt? 233 234 return if (taxReceipt != null) { 235 var donauInfo: DonauInfo? = null 236 api.request("getDonau", GetDonauResponse.serializer()) 237 .onSuccess { donauInfo = it.currentDonauInfo } 238 239 if (donauInfo == null) { 240 DonauStatus.Unset(taxReceipt.donauUrls.distinct()) 241 } else if (taxReceipt.donauUrls.contains(donauInfo!!.donauBaseUrl)) { 242 DonauStatus.Available 243 } else { 244 DonauStatus.Mismatch( 245 donauInfo = donauInfo!!, 246 donauUrls = taxReceipt.donauUrls.distinct(), 247 ) 248 } 249 } else { 250 DonauStatus.Unavailable 251 } 252 } 253 254 fun checkPayForTemplate(url: String) = scope.launch { 255 mPayStatus.value = PayStatus.Loading 256 api.request("checkPayForTemplate", CheckPayTemplateResponse.serializer()) { 257 put("talerPayTemplateUri", url) 258 }.onError { 259 handleError("checkPayForTemplate", it) 260 }.onSuccess { response -> 261 if (response.templateDetails.templateContract.templateType == TemplateType.Paivana) { 262 scope.launch { preparePayForTemplate(url, TemplateParams()) } 263 } else { 264 mPayStatus.value = PayStatus.Checked( 265 details = response.templateDetails, 266 supportedCurrencies = response.supportedCurrencies, 267 ) 268 } 269 } 270 } 271 272 fun preparePayForTemplate(url: String, params: TemplateParams) = scope.launch { 273 mPayStatus.value = PayStatus.Loading 274 api.request("preparePayForTemplate", PreparePayResponse.serializer()) { 275 put("talerPayTemplateUri", url) 276 put("templateParams", JSONObject(BackendManager.json.encodeToString(params))) 277 }.onError { 278 handleError("preparePayForTemplate", it) 279 }.onSuccess { response -> 280 mPayStatus.value = when (response) { 281 is PaymentPossibleResponse -> response.toPayStatusPrepared() 282 is PreparePayResponse.ChoiceSelection -> response.toPayStatusPrepared() 283 284 is InsufficientBalanceResponse -> InsufficientBalance( 285 transactionId = response.transactionId, 286 contractTerms = response.contractTerms, 287 amountRaw = response.amountRaw, 288 balanceDetails = response.balanceDetails, 289 ) 290 291 is AlreadyConfirmedResponse -> AlreadyPaid( 292 transactionId = response.transactionId, 293 ) 294 } 295 } 296 } 297 298 @UiThread 299 fun resetPayStatus() { 300 mPayStatus.postValue(PayStatus.None) 301 } 302 303 private fun handleError(operation: String, error: TalerErrorInfo) { 304 Log.e(TAG, "got $operation error result $error") 305 mPayStatus.postValue(PayStatus.Pending(error = error)) 306 } 307 308 }