diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index ade825296a..aea6884b96 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -26,9 +26,11 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase @@ -172,5 +174,27 @@ internal interface TangemPayDataModule { ): ProduceTangemPayInitialDataUseCase { return ProduceTangemPayInitialDataUseCase(repository = repository) } + + @Provides + fun provideChangeCardFrozenStateUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + ): ChangeCardFrozenStateUseCase { + return ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + ) + } + + @Provides + @Singleton + fun provideStartTangemPayPollingUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): StartTangemPayOrderPollingUseCase { + return StartTangemPayOrderPollingUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt index 0e85fa2f63..a2295443b3 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -7,12 +7,11 @@ import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.ReissueCardRequest -import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayReissueCardStore import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.utils.coroutines.runSuspendCatching @@ -22,6 +21,7 @@ internal class DefaultReissueCardRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, private val tangemPayReissueCardStore: TangemPayReissueCardStore, + private val cardDetailsRepository: TangemPayCardDetailsRepository, ) : TangemPayReissueCardRepository { override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either = @@ -53,14 +53,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun reissueCard( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val response = requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.reissueCard( authHeader = authHeader, body = ReissueCardRequest(cardId = cardId), ) }.bind() - TangemPayReissueOrderInfo( + TangemPayOrderInfo( orderId = response.result.orderId, orderStatus = OrderStatusConverter.convert(response.result.status), ) @@ -77,28 +77,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() if (orderId == null) { return null.right() } - val order = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.bind() - - val result = order.result ?: raise(VisaApiError.Unspecified) - - TangemPayReissueOrderInfo( - orderId = result.id, - orderStatus = when (result.status) { - OrderResponse.Result.Status.NEW -> OrderStatus.NEW - OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING - OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED - OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED - }, - ) + cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind() } private companion object { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 97657f4c0d..cb621a3e3d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.left import arrow.core.raise.catch +import arrow.core.raise.either import arrow.core.right import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.RainCryptoUtil @@ -22,22 +23,17 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds private const val TAG = "TangemPay: CardDetailsRepository" @@ -51,12 +47,8 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val storage: TangemPayStorage, private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val errorConverter: TangemPayErrorConverter, - private val pollingScope: AppCoroutineScope, ) : TangemPayCardDetailsRepository { - private val pollingJobs = mutableMapOf() - private val storePollingMutex = Mutex() - override suspend fun getCardBalance(userWalletId: UserWalletId): Either { return catch( block = { @@ -201,123 +193,43 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( override suspend fun freezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Frozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Unfrozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = true, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } + val result = response.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Unfrozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Frozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = false, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } - } + val result = response.result ?: raise(VisaApiError.Unspecified) - private suspend fun startOrderIdPolling( - userWalletId: UserWalletId, - cardId: String, - orderId: String?, - isFreeze: Boolean, - ) { - if (orderId.isNullOrEmpty()) return - storePollingMutex.withLock { - if (pollingJobs.containsKey(orderId)) return - val pollingJob = pollingScope.launch { - try { - var retryCount = 0 - while (isActive && pollingJobs.containsKey(orderId)) { - delay(duration = 5.seconds) - - val orderStatus = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - } - - orderStatus.onRight { response -> - val status = response.result?.status - if (status == Status.COMPLETED || status == Status.CANCELED) { - // Remove from jobs - pollingJobs.remove(key = orderId) - - // Final card state - val finalState = when { - status == Status.COMPLETED && isFreeze - -> TangemPayCardFrozenState.Frozen - status == Status.COMPLETED && !isFreeze - -> TangemPayCardFrozenState.Unfrozen - else -> return@launch - } - - cardFrozenStateStore.store(cardId, finalState) - } - }.onLeft { error -> - TangemLogger.e("error ${error.errorCode}") - // stop retrying after 3 errors - if (retryCount > MAX_POLLING_RETRIES) { - pollingJobs.remove(key = orderId) - } - } - retryCount++ - } - } catch (e: Exception) { - TangemLogger.e("Error", e) - storePollingMutex.withLock { - pollingJobs.remove(orderId) - } - } - } - pollingJobs[orderId] = pollingJob - } + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun updateCardDisplayName( @@ -376,6 +288,31 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( return cardFrozenStateStore.getSyncOrNull(cardId) } + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) { + cardFrozenStateStore.store(cardId, state) + } + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = either { + val order = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.bind() + + val result = order.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.id, + orderStatus = when (result.status) { + Status.NEW -> OrderStatus.NEW + Status.PROCESSING -> OrderStatus.PROCESSING + Status.COMPLETED -> OrderStatus.COMPLETED + Status.CANCELED -> OrderStatus.CANCELED + }, + ) + } + private suspend fun getPublicKeyBase64(): String { val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment return when (env) { @@ -395,8 +332,4 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( TangemLogger.withTag(TAG).e("Error", throwable) return errorConverter.convert(throwable).left() } - - private companion object { - const val MAX_POLLING_RETRIES = 3 - } } \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt index fadd88988c..17a322804b 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow @@ -65,12 +66,12 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun freezeCard( userWalletId: UserWalletId, cardId: String, - ): Either = real.freezeCard(userWalletId, cardId) + ): Either = real.freezeCard(userWalletId, cardId) override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either = real.unfreezeCard(userWalletId, cardId) + ): Either = real.unfreezeCard(userWalletId, cardId) override fun cardFrozenState(cardId: String): Flow = real.cardFrozenState(cardId) @@ -78,6 +79,14 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? = real.cardFrozenStateSync(cardId) + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) = + real.setCardFrozenState(cardId, state) + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = real.getOrderInfo(userWalletId, orderId) + override suspend fun updateCardDisplayName( cardId: String, userWalletId: UserWalletId, diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index ec2d1affc7..47c4e35082 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.domain.visa" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Project - Core */ api(projects.core.pagination) @@ -34,4 +38,12 @@ dependencies { implementation(deps.moshi) implementation(deps.moshi.kotlin) ksp(deps.moshi.kotlin.codegen) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index fd3d717107..035572167a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -5,4 +5,7 @@ enum class OrderStatus { PROCESSING, COMPLETED, CANCELED, -} \ No newline at end of file +} + +val OrderStatus.isFinalStatus + get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt similarity index 71% rename from domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt index 45945f7204..eebc510bd5 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt @@ -1,6 +1,6 @@ package com.tangem.domain.pay.model -data class TangemPayReissueOrderInfo( +data class TangemPayOrderInfo( val orderId: String, val orderStatus: OrderStatus, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 6778223272..b3eed00dba 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow @@ -24,14 +25,12 @@ interface TangemPayCardDetailsRepository { suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either - suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either - suspend fun unfreezeCard( - userWalletId: UserWalletId, - cardId: String, - ): Either + suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun unfreezeCard(userWalletId: UserWalletId, cardId: String): Either fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) suspend fun updateCardDisplayName( cardId: String, @@ -44,4 +43,6 @@ interface TangemPayCardDetailsRepository { userWalletId: UserWalletId, limit: String, ): Either + + suspend fun getOrderInfo(userWalletId: UserWalletId, orderId: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt index 3f756df4ee..1487f57359 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -4,19 +4,19 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.pay.TangemPayReissueCardFee -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.error.VisaApiError interface TangemPayReissueCardRepository { suspend fun getReissueCardFee(userWalletId: UserWalletId): Either - suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either suspend fun storeReissueOrderId(cardId: String, orderId: String): Either suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either + ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt new file mode 100644 index 0000000000..3aeb3e865f --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt @@ -0,0 +1,45 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.async + +class ChangeCardFrozenStateUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + cardId: String, + isFreezing: Boolean, + ): Either { + val successState = if (isFreezing) TangemPayCardFrozenState.Frozen else TangemPayCardFrozenState.Unfrozen + val failState = if (isFreezing) TangemPayCardFrozenState.Unfrozen else TangemPayCardFrozenState.Frozen + return either { + cardDetailsRepository.setCardFrozenState(cardId, TangemPayCardFrozenState.Pending) + + val order = if (isFreezing) { + cardDetailsRepository.freezeCard(userWalletId, cardId).bind() + } else { + cardDetailsRepository.unfreezeCard(userWalletId, cardId).bind() + } + + val isCompleted = appCoroutineScope.async { + val isCompleted = startTangemPayOrderPollingUseCase(order, userWalletId) + cardDetailsRepository.setCardFrozenState(cardId, if (isCompleted) successState else failState) + isCompleted + }.await() + + if (!isCompleted) raise(VisaApiError.Unspecified) + }.onLeft { + cardDetailsRepository.setCardFrozenState(cardId, failState) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt new file mode 100644 index 0000000000..50ea3fdfd9 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.pay.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.model.isFinalStatus +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import kotlinx.coroutines.delay + +class StartTangemPayOrderPollingUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean { + while (true) { + val newOrder = if (order.orderStatus.isFinalStatus) { + order + } else { + cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull() + } + + if (newOrder != null && newOrder.orderStatus.isFinalStatus) { + paymentAccountStatusFetcher.invoke(userWalletId) + return newOrder.orderStatus == OrderStatus.COMPLETED + } + + delay(POLLING_DELAY) + } + } + + companion object { + private const val POLLING_DELAY = 3000L + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt new file mode 100644 index 0000000000..67fd67b1a5 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt @@ -0,0 +1,107 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class ChangeCardFrozenStateUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxUnitFun = true) + private val startPollingUseCase: StartTangemPayOrderPollingUseCase = mockk() + + @Test + fun `GIVEN freezeCard fails WHEN invoke with isFreezing=true THEN sets Pending then Unfrozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN unfreezeCard fails WHEN invoke with isFreezing=false THEN sets Pending then Frozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN freeze succeeds and order COMPLETED WHEN invoke with isFreezing=true THEN sets Frozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + } + + @Test + fun `GIVEN unfreeze succeeds and order COMPLETED WHEN invoke with isFreezing=false THEN sets Unfrozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + } + + private fun TestScope.createUseCase() = ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startPollingUseCase, + appCoroutineScope = TestAppCoroutineScope(this), + ) + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val CARD_ID = "card-test-id" + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt new file mode 100644 index 0000000000..86ab6a5bf2 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class StartTangemPayOrderPollingUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk() + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + + private val useCase = StartTangemPayOrderPollingUseCase( + cardDetailsRepository = cardDetailsRepository, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + + @Test + fun `GIVEN order already COMPLETED WHEN invoke THEN returns true without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN order already CANCELED WHEN invoke THEN returns false without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN processing order WHEN poll returns COMPLETED THEN returns true and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN processing order WHEN poll returns CANCELED THEN returns false and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN new order WHEN getOrderInfo fails once then returns COMPLETED THEN returns true after two polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + VisaApiError.Unspecified.left(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 2) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + } + + @Test + fun `GIVEN processing order WHEN multiple non-final polls then COMPLETED THEN returns true after all polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 3) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt index 13f1c7c061..918ad5d9af 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -7,7 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.ui.TangemPayReissueCardContent @@ -34,7 +34,7 @@ internal class TangemPayReissueCardComponent( } internal interface ReissueCardListener { - fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) + fun onReissueOrderCreate(order: TangemPayOrderInfo) fun onDismissReissueCard() fun onClickAddFunds() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index f5c7e0a5e5..c0f9d07ad2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -28,12 +29,12 @@ import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.ReissueCardListener import com.tangem.features.tangempay.components.TangemPayCardPageComponent @@ -63,12 +64,14 @@ internal class TangemPayCardPageModel @Inject constructor( private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, private val reissueCardRepository: TangemPayReissueCardRepository, + private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() private val addToWalletBannerJobHolder = JobHolder() private val addFundsJobHolder = JobHolder() + private val frozenStateJobHolder = JobHolder() val uiState: StateFlow field = MutableStateFlow( @@ -119,12 +122,18 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_change_pin), onSettingClick = { onClickChangePIN(card.hasPinCode) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.CHANGE_PIN_ROW, + testTag = TangemPayTestTags.CHANGE_PIN_ROW, ), TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + title = TextReference.Res( + if (card.isFrozen) { + R.string.tangempay_card_details_unfreeze_card + } else { + R.string.tangempay_card_details_freeze_card + }, + ), onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.FREEZE_CARD_ROW, + testTag = TangemPayTestTags.FREEZE_CARD_ROW, ), TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_reissue_card), @@ -152,6 +161,8 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun onClickFreezeOrUnfreezeCard(isFrozen: Boolean) { + if (frozenStateJobHolder.isActive) return + val message = if (isFrozen) { TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard) } else { @@ -165,7 +176,7 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) } - override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { + override fun onReissueOrderCreate(order: TangemPayOrderInfo) { bottomSheetNavigation.dismiss() onReissueOrderStatusReceived(order.orderStatus) if (order.orderStatus != OrderStatus.CANCELED) { @@ -238,40 +249,34 @@ internal class TangemPayCardPageModel @Inject constructor( private fun freezeCard() { modelScope.launch { - cardDetailsRepository.freezeCard( + changeCardFrozenStateUseCase( userWalletId = params.userWalletId, cardId = params.config.cardId, + isFreezing = true, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Frozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun unfreezeCard() { modelScope.launch { - cardDetailsRepository.unfreezeCard( + changeCardFrozenStateUseCase( userWalletId = params.userWalletId, cardId = params.config.cardId, + isFreezing = false, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Unfrozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun fetchAddToWalletBanner() {