WithdrawManager.kt (19876B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2024 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.withdraw 18 19 import android.content.Context 20 import android.util.Log 21 import androidx.annotation.UiThread 22 import kotlinx.coroutines.CoroutineScope 23 import kotlinx.coroutines.flow.MutableStateFlow 24 import kotlinx.coroutines.flow.StateFlow 25 import kotlinx.coroutines.flow.asStateFlow 26 import kotlinx.coroutines.flow.update 27 import kotlinx.coroutines.flow.updateAndGet 28 import kotlinx.coroutines.launch 29 import kotlinx.serialization.SerialName 30 import kotlinx.serialization.Serializable 31 import net.taler.common.Amount 32 import net.taler.wallet.main.TAG 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.exchanges.ExchangeFees 37 import net.taler.wallet.exchanges.ExchangeItem 38 import net.taler.wallet.exchanges.ExchangeManager 39 import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails 40 import net.taler.wallet.withdraw.WithdrawStatus.Status.* 41 import kotlinx.coroutines.runBlocking 42 import net.taler.common.CurrencySpecification 43 import net.taler.wallet.R 44 import net.taler.wallet.transactions.TransactionMajorState 45 import net.taler.wallet.transactions.TransactionManager 46 47 sealed class TestWithdrawStatus { 48 data object None : TestWithdrawStatus() 49 data object Withdrawing : TestWithdrawStatus() 50 data object Success : TestWithdrawStatus() 51 data class Error(val message: String) : TestWithdrawStatus() 52 } 53 54 data class WithdrawStatus( 55 val status: Status = None, 56 57 // common details 58 val talerWithdrawUri: String? = null, 59 val exchangeBaseUrl: String? = null, 60 val transactionId: String? = null, 61 val error: TalerErrorInfo? = null, 62 63 // received details 64 val uriInfo: WithdrawalDetailsForUri? = null, 65 val amountInfo: WithdrawalDetailsForAmount? = null, 66 67 // calculated selections (based on amountInfo) 68 val selectedAmount: Amount? = null, 69 val selectedScope: ScopeInfo? = null, 70 val selectedSpec: CurrencySpecification? = null, 71 72 // manual transfer 73 val manualTransferResponse: AcceptManualWithdrawalResponse? = null, 74 val withdrawalTransfers: List<TransferData> = emptyList(), 75 ) { 76 enum class Status { 77 None, 78 Loading, 79 Updating, 80 Confirming, 81 InfoReceived, 82 AlreadyConfirmed, 83 TosReviewRequired, 84 ManualTransferRequired, 85 Success, 86 Error, 87 } 88 89 val isCashAcceptor get() = uriInfo != null 90 && uriInfo.amount == null 91 && !uriInfo.editableAmount 92 } 93 94 sealed class TransferData { 95 abstract val subject: String 96 abstract val amountRaw: Amount 97 abstract val amountEffective: Amount 98 abstract val transferAmount: Amount 99 abstract val withdrawalAccount: WithdrawalExchangeAccountDetails 100 101 val currency get() = withdrawalAccount.transferAmount?.currency 102 103 data class Taler( 104 override val subject: String, 105 override val amountRaw: Amount, 106 override val amountEffective: Amount, 107 override val transferAmount: Amount, 108 override val withdrawalAccount: WithdrawalExchangeAccountDetails, 109 val receiverName: String? = null, 110 val account: String, 111 val exchangeBaseUrl: String, 112 ): TransferData() 113 114 data class IBAN( 115 override val subject: String, 116 override val amountRaw: Amount, 117 override val amountEffective: Amount, 118 override val transferAmount: Amount, 119 override val withdrawalAccount: WithdrawalExchangeAccountDetails, 120 val receiverName: String? = null, 121 val receiverPostalCode: String? = null, 122 val receiverTown: String? = null, 123 val iban: String, 124 ): TransferData() 125 126 data class Cyclos( 127 override val subject: String, 128 override val amountRaw: Amount, 129 override val amountEffective: Amount, 130 override val transferAmount: Amount, 131 override val withdrawalAccount: WithdrawalExchangeAccountDetails, 132 val host: String, // host + fpath 133 val account: String, // FIXME: account ID not used at all? 134 val receiverName: String, // FIXME: actual ID used for payments? 135 ): TransferData() 136 137 data class Bitcoin( 138 override val subject: String, 139 override val amountRaw: Amount, 140 override val amountEffective: Amount, 141 override val transferAmount: Amount, 142 override val withdrawalAccount: WithdrawalExchangeAccountDetails, 143 val account: String, 144 val segwitAddresses: List<String>, 145 ): TransferData() 146 } 147 148 @Serializable 149 enum class WithdrawalOperationStatusFlag { 150 Unknown, 151 152 @SerialName("pending") 153 Pending, 154 155 @SerialName("selected") 156 Selected, 157 158 @SerialName("aborted") 159 Aborted, 160 161 @SerialName("confirmed") 162 Confirmed, 163 } 164 165 @Serializable 166 data class PrepareBankIntegratedWithdrawalResponse( 167 val transactionId: String, 168 val info: WithdrawalDetailsForUri, 169 ) 170 171 @Serializable 172 data class WithdrawalDetailsForUri( 173 val amount: Amount? = null, 174 val currency: String, 175 val editableAmount: Boolean = false, 176 val maxAmount: Amount? = null, 177 val wireFee: Amount? = null, 178 val defaultExchangeBaseUrl: String? = null, 179 val possibleExchanges: List<ExchangeItem> = emptyList(), 180 val status: WithdrawalOperationStatusFlag, 181 ) 182 183 @Serializable 184 data class WithdrawalDetailsForAmount( 185 /** 186 * Amount that the user will transfer to the exchange. 187 */ 188 val amountRaw: Amount, 189 190 /** 191 * Amount that will be added to the user's wallet balance. 192 */ 193 val amountEffective: Amount, 194 195 /** 196 * Ways to pay the exchange, including accounts that require currency conversion. 197 */ 198 val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>, 199 200 /** 201 * If the exchange supports age-restricted coins it will return 202 * the array of ages. 203 */ 204 val ageRestrictionOptions: List<Int>? = null, 205 206 /** 207 * Scope info of the currency withdrawn. 208 */ 209 val scopeInfo: ScopeInfo, 210 ) 211 212 @Serializable 213 data class WithdrawExchangeResponse( 214 val exchangeBaseUrl: String, 215 val amount: Amount? = null, 216 ) 217 218 @Serializable 219 data class AcceptWithdrawalResponse( 220 val transactionId: String, 221 ) 222 223 @Serializable 224 data class AcceptManualWithdrawalResponse( 225 val reservePub: String, 226 val withdrawalAccountsList: List<WithdrawalExchangeAccountDetails>, 227 val transactionId: String, 228 ) 229 230 @Serializable 231 data class GetQrCodesForPaytoResponse( 232 val codes: List<QrCodeSpec>, 233 ) 234 235 @Serializable 236 data class QrCodeSpec( 237 val type: Type = Type.Unknown, 238 val qrContent: String, 239 ) { 240 @Serializable 241 enum class Type { 242 Unknown, 243 244 @SerialName("epc-qr") 245 EpcQr, 246 247 @SerialName("spc") 248 SPC, 249 } 250 } 251 252 class WithdrawManager( 253 private val api: WalletBackendApi, 254 private val scope: CoroutineScope, 255 private val exchangeManager: ExchangeManager, 256 private val transactionManager: TransactionManager, 257 ) { 258 private val _withdrawStatus = MutableStateFlow(WithdrawStatus()) 259 val withdrawStatus: StateFlow<WithdrawStatus> = _withdrawStatus.asStateFlow() 260 261 private val _withdrawTestStatus = MutableStateFlow<TestWithdrawStatus>(TestWithdrawStatus.None) 262 val withdrawTestStatus: StateFlow<TestWithdrawStatus> = _withdrawTestStatus.asStateFlow() 263 264 var exchangeFees: ExchangeFees? = null 265 private set 266 267 fun withdrawTestBalance() = scope.launch { 268 _withdrawTestStatus.value = TestWithdrawStatus.Withdrawing 269 api.request<Unit>("withdrawTestBalance") { 270 put("amount", "KUDOS:10") 271 put("corebankApiBaseUrl", "https://bank.demo.taler.net/") 272 put("exchangeBaseUrl", "https://exchange.demo.taler.net/") 273 put("useForeignAccount", true) 274 }.onError { 275 _withdrawTestStatus.value = TestWithdrawStatus.Error(it.userFacingMsg) 276 }.onSuccess { 277 _withdrawTestStatus.value = TestWithdrawStatus.Success 278 } 279 } 280 281 @UiThread 282 fun resetWithdrawal() { 283 _withdrawStatus.value = WithdrawStatus() 284 } 285 286 @UiThread 287 fun resetTestWithdrawal() { 288 _withdrawTestStatus.value = TestWithdrawStatus.None 289 } 290 291 fun prepareBankIntegratedWithdrawal( 292 uri: String, 293 context: Context, 294 loading: Boolean = true, 295 ) = scope.launch { 296 _withdrawStatus.update { 297 WithdrawStatus( 298 talerWithdrawUri = uri, 299 status = if (loading) Loading else Updating, 300 ) 301 } 302 303 // first get URI details 304 api.request( 305 "prepareBankIntegratedWithdrawal", 306 PrepareBankIntegratedWithdrawalResponse.serializer(), 307 ) { 308 put("talerWithdrawUri", uri) 309 }.onError { error -> 310 handleError("prepareBankIntegratedWithdrawal", error) 311 }.onSuccess { details -> 312 Log.d(TAG, "Withdraw details: $details") 313 scope.launch { 314 val tx = transactionManager.getTransactionById(details.transactionId) 315 ?: error("transaction ${details.transactionId} not found") 316 317 val exchangeBaseUrl = details.info.defaultExchangeBaseUrl 318 ?: details.info.possibleExchanges.firstOrNull()?.exchangeBaseUrl 319 320 // Handle no exchanges configured by bank. 321 if (exchangeBaseUrl == null) { 322 _withdrawStatus.updateAndGet { value -> 323 value.copy( 324 status = Error, 325 error = TalerErrorInfo.makeCustomError( 326 context.getString(R.string.withdraw_error_empty_exchanges)), 327 ) 328 } 329 return@launch 330 } 331 332 val status = _withdrawStatus.updateAndGet { value -> 333 updateSelections( 334 value.copy( 335 status = if (tx.txState.major == TransactionMajorState.Dialog) { 336 InfoReceived 337 } else { 338 AlreadyConfirmed 339 }, 340 uriInfo = details.info, 341 exchangeBaseUrl = details.info.defaultExchangeBaseUrl 342 ?: details.info.possibleExchanges.firstOrNull()?.exchangeBaseUrl 343 ) 344 ) 345 } 346 347 // then extend with amount details (not for cash acceptor) 348 if (!status.isCashAcceptor) { 349 getWithdrawalDetailsForAmount( 350 amount = details.info.amount 351 ?: Amount.zero(details.info.currency), 352 defaultExchangeBaseUrl = details.info.defaultExchangeBaseUrl, 353 loading = loading, 354 ) 355 } 356 } 357 } 358 } 359 360 fun getWithdrawalDetailsForAmount( 361 amount: Amount, 362 scopeInfo: ScopeInfo? = null, 363 defaultExchangeBaseUrl: String? = null, 364 loading: Boolean = true, 365 ) = scope.launch { 366 // complete exchangeBaseUrl if missing 367 val exchange = scopeInfo?.let { exchangeManager.findExchange(it) } 368 ?: defaultExchangeBaseUrl?.let { exchangeManager.findExchange(it) } 369 ?: exchangeManager.findExchange(amount.currency) 370 if (exchange != null) { 371 getWithdrawalDetails( 372 amount = amount, 373 exchange = exchange, 374 loading = loading, 375 ) 376 } 377 } 378 379 fun getWithdrawalDetailsForExchange( 380 exchangeBaseUrl: String, 381 amount: Amount? = null, 382 loading: Boolean = true, 383 ) = scope.launch { 384 // complete amount if missing 385 val exchange = exchangeManager 386 .findExchangeByUrl(exchangeBaseUrl) 387 if (exchange != null) { 388 val amount = amount 389 ?: exchange.currency?.let { Amount.zero(it)} 390 if (amount != null) { 391 _withdrawStatus.update { value -> 392 updateSelections(value.copy( 393 exchangeBaseUrl = exchangeBaseUrl)) 394 } 395 396 getWithdrawalDetails( 397 amount = amount, 398 exchange = exchange, 399 loading = loading, 400 ) 401 } 402 } 403 } 404 405 fun getWithdrawalDetails( 406 amount: Amount, 407 exchange: ExchangeItem, 408 loading: Boolean = true, 409 ) = scope.launch { 410 // do not interrupt confirmation 411 if (_withdrawStatus.value.status == Confirming) { 412 return@launch 413 } 414 415 _withdrawStatus.update { status -> 416 status.copy(status = if (loading) Loading else Updating) 417 } 418 419 api.request("getWithdrawalDetailsForAmount", WithdrawalDetailsForAmount.serializer()) { 420 put("exchangeBaseUrl", exchange.exchangeBaseUrl) 421 put("amount", amount.toJSONString()) 422 }.onError { error -> 423 handleError("getWithdrawalDetailsForAmount", error) 424 }.onSuccess { details -> 425 scope.launch { 426 _withdrawStatus.update { value -> 427 updateSelections(value.copy( 428 status = if (!exchange.tosStatus.isAccepted()) { 429 TosReviewRequired 430 } else { 431 InfoReceived 432 }, 433 amountInfo = details, 434 exchangeBaseUrl = exchange.exchangeBaseUrl, 435 )) 436 } 437 } 438 } 439 } 440 441 private suspend fun updateSelections( 442 status: WithdrawStatus, 443 ): WithdrawStatus { 444 val selectedAmount = status.amountInfo?.amountRaw ?: status.uriInfo?.amount 445 val selectedScope = status.amountInfo?.scopeInfo 446 ?: status.uriInfo?.defaultExchangeBaseUrl?.let { url -> 447 exchangeManager.findExchangeByUrl(url)?.scopeInfo 448 } 449 val selectedSpec = selectedScope?.let { scope -> 450 exchangeManager.getSpecForScopeInfo(scope) 451 } ?: selectedAmount?.currency?.let { currency -> 452 exchangeManager.getSpecForCurrency(currency) 453 } 454 455 return status.copy( 456 selectedAmount = selectedAmount, 457 selectedScope = selectedScope, 458 selectedSpec = selectedSpec, 459 ) 460 } 461 462 @UiThread 463 fun prepareManualWithdrawal(uri: String) = scope.launch { 464 _withdrawStatus.value = WithdrawStatus(status = Loading) 465 api.request("prepareWithdrawExchange", WithdrawExchangeResponse.serializer()) { 466 put("talerUri", uri) 467 }.onError { 468 handleError("prepareWithdrawExchange", it) 469 }.onSuccess { 470 getWithdrawalDetailsForExchange( 471 exchangeBaseUrl = it.exchangeBaseUrl, 472 amount = it.amount, 473 ) 474 } 475 } 476 477 @UiThread 478 fun refreshTosStatus(exchanges: List<ExchangeItem>) = scope.launch { 479 _withdrawStatus.update { status -> 480 var newStatus = status 481 status.exchangeBaseUrl?.let { exchangeBaseUrl -> 482 exchanges.find { it.exchangeBaseUrl == exchangeBaseUrl }?.let { exchange -> 483 if (exchange.tosStatus.isAccepted()) { 484 newStatus = status.copy(status = InfoReceived) 485 } 486 } 487 } ?: run { 488 Log.d(TAG, "could not refresh ToS status, exchange ${status.exchangeBaseUrl} was not found") 489 } 490 newStatus 491 } 492 } 493 494 @UiThread 495 fun acceptWithdrawal(restrictAge: Int? = null) = scope.launch { 496 val status = _withdrawStatus.updateAndGet { value -> 497 value.copy(status = Confirming) 498 } 499 500 if (status.talerWithdrawUri == null) { 501 acceptManualWithdrawal(status, restrictAge) 502 } else { 503 acceptBankIntegratedWithdrawal(status, restrictAge) 504 } 505 } 506 507 private suspend fun acceptBankIntegratedWithdrawal( 508 status: WithdrawStatus, 509 restrictAge: Int? = null, 510 ) { 511 val exchangeBaseUrl = status.exchangeBaseUrl ?: error("no exchangeBaseUrl") 512 val talerWithdrawUri = status.talerWithdrawUri ?: error("no talerWithdrawUri") 513 val amountInfo = status.amountInfo 514 515 api.request("acceptBankIntegratedWithdrawal", AcceptWithdrawalResponse.serializer()) { 516 restrictAge?.let { put("restrictAge", it) } 517 amountInfo?.let { put("amount", it.amountRaw.toJSONString()) } 518 put("exchangeBaseUrl", exchangeBaseUrl) 519 put("talerWithdrawUri", talerWithdrawUri) 520 }.onError { error -> 521 handleError("acceptBankIntegratedWithdrawal", error) 522 }.onSuccess { response -> 523 _withdrawStatus.update { value -> 524 value.copy( 525 status = Success, 526 transactionId = response.transactionId, 527 ) 528 } 529 } 530 } 531 532 private suspend fun acceptManualWithdrawal( 533 status: WithdrawStatus, 534 restrictAge: Int? = null, 535 ) { 536 val exchangeBaseUrl = status.exchangeBaseUrl ?: error("no exchangeBaseUrl") 537 val amountInfo = status.amountInfo ?: error("no amountInfo") 538 539 api.request("acceptManualWithdrawal", AcceptManualWithdrawalResponse.serializer()) { 540 restrictAge?.let { put("restrictAge", it) } 541 put("exchangeBaseUrl", exchangeBaseUrl) 542 put("amount", amountInfo.amountRaw.toJSONString()) 543 }.onError { error -> 544 handleError("acceptManualWithdrawal", error) 545 }.onSuccess { response -> 546 _withdrawStatus.update { value -> 547 createManualTransfer(value, response) 548 } 549 } 550 } 551 552 fun getQrCodesForPayto(uri: String): List<QrCodeSpec> = runBlocking { 553 var codes = emptyList<QrCodeSpec>() 554 api.request("getQrCodesForPayto", GetQrCodesForPaytoResponse.serializer()) { 555 put("paytoUri", uri) 556 }.onError { error -> 557 handleError("getQrCodesForPayto", error) 558 }.onSuccess { response -> 559 codes = response.codes 560 } 561 562 return@runBlocking codes 563 } 564 565 private fun handleError(operation: String, error: TalerErrorInfo) { 566 Log.e(TAG, "Error $operation $error") 567 _withdrawStatus.update { value -> 568 value.copy(status = Error, error = error) 569 } 570 } 571 572 private fun createManualTransfer( 573 status: WithdrawStatus, 574 response: AcceptManualWithdrawalResponse, 575 ) = status.copy( 576 status = ManualTransferRequired, 577 manualTransferResponse = response, 578 transactionId = response.transactionId, 579 withdrawalTransfers = response.withdrawalAccountsList.mapNotNull { 580 val details = status.amountInfo ?: return@mapNotNull null 581 it.getTransferDetails( 582 amountRaw = details.amountRaw, 583 amountEffective = details.amountEffective, 584 ) 585 }, 586 ) 587 }