taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }