From ad0b09deaef13d55fc32092b341b59bc728954a0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Jun 2026 04:32:24 -0700 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 6 + .../pay/models/request/CloseCardRequest.kt | 9 ++ .../pay/models/response/CloseCardResponse.kt | 15 +++ .../datasource/di/TangemPayStoresModule.kt | 10 ++ .../visa/DefaultTangemPayCloseCardStore.kt | 29 +++++ .../local/visa/TangemPayCloseCardStore.kt | 8 ++ .../entity/PaymentAccountStatusValueDM.kt | 2 +- .../PaymentAccountStatusValueDMConverter.kt | 5 +- .../tangem/data/pay/di/TangemPayDataModule.kt | 20 ++++ .../DefaultPaymentAccountStatusFetcher.kt | 46 +++++--- .../repository/DefaultCloseCardRepository.kt | 52 +++++++++ .../DefaultReissueCardRepository.kt | 14 +-- .../tangem/domain/models/pay/TangemPayCard.kt | 3 +- .../domain/models/pay/TangemPayCardState.kt | 38 +++++++ .../TangemPayCloseCardRepository.kt | 16 +++ .../TangemPayReissueCardRepository.kt | 5 +- .../pay/usecase/CloseTangemPayCardUseCase.kt | 33 ++++++ .../usecase/CloseTangemPayCardUseCaseTest.kt | 104 ++++++++++++++++++ .../model/TangemPayCardDetailsBlockModel.kt | 5 +- .../tangempay/model/TangemPayCardPageModel.kt | 3 +- .../tangempay/model/TangemPayDetailsModel.kt | 3 +- .../TangemPayCardDataTransformer.kt | 3 +- .../setup/TangemPayCardLimitSetupModelTest.kt | 5 +- .../converter/TangemPayMainBlockConverter.kt | 3 +- 24 files changed, 397 insertions(+), 40 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/CloseCardRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CloseCardResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCloseCardStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCloseCardStore.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCloseCardRepository.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardState.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCloseCardRepository.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCase.kt create mode 100644 domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCaseTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 56359bc593..d930dbbba1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -97,6 +97,12 @@ interface TangemPayApi { @Body body: ReissueCardRequest, ): ApiResponse + @POST("v1/customer/card/close") + suspend fun closeCard( + @Header("Authorization") authHeader: String, + @Body body: CloseCardRequest, + ): ApiResponse + @POST("v1/customer/card/withdraw/data") suspend fun getWithdrawData( @Header("Authorization") authHeader: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/CloseCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/CloseCardRequest.kt new file mode 100644 index 0000000000..5e04c24da3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/CloseCardRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CloseCardRequest( + @Json(name = "card_id") val cardId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CloseCardResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CloseCardResponse.kt new file mode 100644 index 0000000000..fcee899c0e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CloseCardResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CloseCardResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "order_id") val orderId: String, + @Json(name = "status") val status: OrderResponse.Result.Status, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt index f0f88d8c24..3b3b111fdf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -3,8 +3,10 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.TangemPayCloseCardStore import com.tangem.datasource.local.visa.TangemPayReissueCardStore import dagger.Module import dagger.Provides @@ -32,4 +34,12 @@ internal object TangemPayStoresModule { prefs = prefs, ) } + + @Provides + @Singleton + fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore { + return DefaultTangemPayCloseCardStore( + prefs = prefs, + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCloseCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCloseCardStore.kt new file mode 100644 index 0000000000..1bc95d5f94 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayCloseCardStore.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.local.visa + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store + +internal class DefaultTangemPayCloseCardStore( + private val prefs: AppPreferencesStore, +) : TangemPayCloseCardStore { + + override suspend fun setCloseOrderId(cardId: String, orderId: String?) { + if (orderId == null) { + prefs.edit { it.remove(getCloseKey(cardId)) } + } else { + prefs.store( + key = getCloseKey(cardId), + value = orderId, + ) + } + } + + override suspend fun getOrderId(cardId: String): String? { + return prefs.getSyncOrNull(key = getCloseKey(cardId)) + } + + private fun getCloseKey(cardId: String) = stringPreferencesKey("tangem_pay_close_card_$cardId") +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCloseCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCloseCardStore.kt new file mode 100644 index 0000000000..1aca215a85 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayCloseCardStore.kt @@ -0,0 +1,8 @@ +package com.tangem.datasource.local.visa + +interface TangemPayCloseCardStore { + + suspend fun setCloseOrderId(cardId: String, orderId: String?) + + suspend fun getOrderId(cardId: String): String? +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 9cc5b5d36e..102dd68e4a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -86,6 +86,6 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?, @Json(name = "frozen_state") val frozenState: String, @Json(name = "last_digits") val lastDigits: String, - @Json(name = "is_reissuing") val isReissuing: Boolean, + @Json(name = "state") val state: String, ) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index d2b8c2df77..d48c978b7a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.models.pay.TangemPayCardLimitData import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject import javax.inject.Singleton @@ -52,7 +53,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( adminDailyLimit = card.limit?.adminCardLimit?.amount, frozenState = card.frozenState.toString(), lastDigits = card.lastDigits, - isReissuing = card.isReissuing, + state = card.state.toString(), ) }, ) @@ -108,7 +109,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ), frozenState = TangemPayCardFrozenState.fromString(card.frozenState), lastDigits = card.lastDigits, - isReissuing = card.isReissuing, + state = TangemPayCardState.fromString(card.state), ) }, ) 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 7cfc299b35..cf7611d24a 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 @@ -28,6 +28,7 @@ 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.CloseTangemPayCardUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase @@ -73,6 +74,10 @@ internal interface TangemPayDataModule { @Singleton fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository + @Binds + @Singleton + fun bindCloseCardRepository(repository: DefaultCloseCardRepository): TangemPayCloseCardRepository + @Binds @Singleton fun bindTangemPayCryptoCurrencyFactory( @@ -220,5 +225,20 @@ internal interface TangemPayDataModule { paymentAccountStatusFetcher = paymentAccountStatusFetcher, ) } + + @Provides + fun provideCloseTangemPayCardUseCase( + closeCardRepository: TangemPayCloseCardRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): CloseTangemPayCardUseCase { + return CloseTangemPayCardUseCase( + closeCardRepository = closeCardRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 2d84cdaa12..83b6e53838 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -23,7 +23,10 @@ import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.pay.model.isFinalStatus import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayCloseCardRepository import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.isSecurityExposed import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -36,7 +39,7 @@ import kotlin.time.Duration.Companion.minutes private const val TAG = "PaymentAccountStatusFetcher" -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val paymentAccountStatusesStore: PaymentAccountStatusesStore, private val onboardingRepository: OnboardingRepository, @@ -46,6 +49,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, private val eligibilityManager: TangemPayEligibilityManager, private val reissueCardRepository: TangemPayReissueCardRepository, + private val closeCardRepository: TangemPayCloseCardRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository, ) : PaymentAccountStatusFetcher { @@ -299,16 +303,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { - val reissueOrder = reissueCardRepository.getReissueOrderInfo( - userWalletId = userWalletId, - cardId = productInstance.cardId, - ).getOrNull() - - val isReissuing = reissueOrder != null && - reissueOrder.orderStatus != OrderStatus.CANCELED && - reissueOrder.orderStatus != OrderStatus.COMPLETED - - val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(productInstance.cardId) + val cardId = productInstance.cardId + val cardState = getCardState(cardId, userWalletId) + val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId) val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -321,7 +318,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( cryptoCurrency = cryptoCurrency, cards = listOf( TangemPayCard( - id = productInstance.cardId, + id = cardId, hasPinCode = cardInfo.isPinSet, displayName = productInstance.displayName, limit = TangemPayCardLimitData( @@ -334,12 +331,35 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( productInstance.frozenState }, lastDigits = cardInfo.lastFourDigits, - isReissuing = isReissuing, + state = cardState, ), ), ) } + private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState { + val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull() + val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull() + return if (closingOrderId != null) { + val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull() + if (order != null && order.orderStatus.isFinalStatus) { + closeCardRepository.setCloseOrderId(cardId, null) + TangemPayCardState.Active + } else { + TangemPayCardState.Closing + } + } else if (reissueOrderId != null) { + val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull() + if (order != null && order.orderStatus.isFinalStatus) { + TangemPayCardState.Active + } else { + TangemPayCardState.Reissuing + } + } else { + TangemPayCardState.Active + } + } + private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { return when (this) { is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCloseCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCloseCardRepository.kt new file mode 100644 index 0000000000..3dfe1e7804 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCloseCardRepository.kt @@ -0,0 +1,52 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +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.CloseCardRequest +import com.tangem.datasource.local.visa.TangemPayCloseCardStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCloseCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.runSuspendCatching +import javax.inject.Inject + +internal class DefaultCloseCardRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, + private val tangemPayCloseCardStore: TangemPayCloseCardStore, +) : TangemPayCloseCardRepository { + + override suspend fun closeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.closeCard( + authHeader = authHeader, + body = CloseCardRequest(cardId = cardId), + ) + }.bind() + TangemPayOrderInfo( + orderId = response.result.orderId, + orderStatus = OrderStatusConverter.convert(response.result.status), + ) + } + + override suspend fun setCloseOrderId(cardId: String, orderId: String?): Either = + runSuspendCatching { + tangemPayCloseCardStore.setCloseOrderId(cardId, orderId) + }.fold( + onSuccess = { Unit.right() }, + onFailure = { Either.Left(VisaApiError.Unspecified) }, + ) + + override suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either = + either { + runSuspendCatching { tangemPayCloseCardStore.getOrderId(cardId) }.getOrNull() + } +} \ 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 a2295443b3..6f4f0fed54 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 @@ -11,7 +11,6 @@ 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.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 @@ -21,7 +20,6 @@ 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 = @@ -74,17 +72,11 @@ internal class DefaultReissueCardRepository @Inject constructor( onFailure = { Either.Left(VisaApiError.Unspecified) }, ) - override suspend fun getReissueOrderInfo( + override suspend fun getReissueOrderId( userWalletId: UserWalletId, cardId: String, - ): Either = either { - val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() - - if (orderId == null) { - return null.right() - } - - cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind() + ): Either = either { + runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() } private companion object { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt index 367e6976e6..a344236e15 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.Serializable * @property limit spending limit configuration for the card; `null` if not configured or not yet loaded. * @property frozenState whether the card is currently frozen (blocked for payments). * @property lastDigits The last four digits of the card number. + * @property state current lifecycle state of the card. */ @Serializable data class TangemPayCard( @@ -22,7 +23,7 @@ data class TangemPayCard( @SerialName("limit") val limit: TangemPayCardLimitData?, @SerialName("frozen_state") val frozenState: TangemPayCardFrozenState, @SerialName("last_digits") val lastDigits: String, - @SerialName("is_reissuing") val isReissuing: Boolean, + @SerialName("state") val state: TangemPayCardState, ) val TangemPayCard.isFrozen diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardState.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardState.kt new file mode 100644 index 0000000000..0521d18fc0 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardState.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.models.pay + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale + +/** + * Lifecycle state of a Tangem Pay card. + */ +@Serializable +enum class TangemPayCardState { + /** Card is operational and ready to use. */ + @SerialName("Active") + Active, + + /** A reissue order is in progress; the card is being replaced. */ + @SerialName("Reissuing") + Reissuing, + + /** A close order is in progress; the card is being closed. */ + @SerialName("Closing") + Closing, + ; + + override fun toString() = when (this) { + Active -> "Active" + Reissuing -> "Reissuing" + Closing -> "Closing" + } + + companion object { + fun fromString(value: String) = when (value.lowercase(Locale.US)) { + "reissuing" -> Reissuing + "closing" -> Closing + else -> Active + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCloseCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCloseCardRepository.kt new file mode 100644 index 0000000000..14cc9376e6 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCloseCardRepository.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.visa.error.VisaApiError + +interface TangemPayCloseCardRepository { + + suspend fun closeCard(userWalletId: UserWalletId, cardId: String): Either + + suspend fun setCloseOrderId(cardId: String, orderId: String?): Either + + suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: 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 1487f57359..f83ce75da9 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 @@ -15,8 +15,5 @@ interface TangemPayReissueCardRepository { suspend fun storeReissueOrderId(cardId: String, orderId: String): Either - suspend fun getReissueOrderInfo( - userWalletId: UserWalletId, - cardId: String, - ): Either + suspend fun getReissueOrderId(userWalletId: UserWalletId, cardId: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCase.kt new file mode 100644 index 0000000000..cf761d96a9 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCase.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +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.repository.TangemPayCloseCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.launch + +class CloseTangemPayCardUseCase( + private val closeCardRepository: TangemPayCloseCardRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke(userWalletId: UserWalletId, cardId: String): Either = either { + val order = closeCardRepository.closeCard(userWalletId, cardId).bind() + + if (order.orderStatus == OrderStatus.CANCELED) { + raise(VisaApiError.Unspecified) + } + + closeCardRepository.setCloseOrderId(cardId, order.orderId) + paymentAccountStatusFetcher.invoke(userWalletId) + + appCoroutineScope.launch { + startTangemPayOrderPollingUseCase(order, userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCaseTest.kt new file mode 100644 index 0000000000..ebb9347195 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CloseTangemPayCardUseCaseTest.kt @@ -0,0 +1,104 @@ +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.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCloseCardRepository +import com.tangem.domain.visa.error.VisaApiError +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class CloseTangemPayCardUseCaseTest { + + private val closeCardRepository: TangemPayCloseCardRepository = mockk(relaxUnitFun = true) + private val startPollingUseCase: StartTangemPayOrderPollingUseCase = mockk() + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + + @Test + fun `GIVEN closeCard fails WHEN invoke THEN returns Left and skips store, fetch and polling`() = runTest { + val useCase = createUseCase() + coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID) + + assertThat(result.isLeft()).isTrue() + coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) } + coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any()) } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN closeCard returns CANCELED order WHEN invoke THEN returns Left and skips store, fetch and polling`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED) + coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + + val result = useCase(USER_WALLET_ID, CARD_ID) + + assertThat(result.isLeft()).isTrue() + coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) } + coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any()) } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN closeCard returns PROCESSING order WHEN invoke THEN stores order id, fetches status and starts polling`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) + paymentAccountStatusFetcher.invoke(USER_WALLET_ID) + startPollingUseCase(order, USER_WALLET_ID) + } + } + + @Test + fun `GIVEN closeCard returns COMPLETED order WHEN invoke THEN stores order id, fetches status and starts polling`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 1) { startPollingUseCase(order, USER_WALLET_ID) } + } + + private fun createUseCase() = CloseTangemPayCardUseCase( + closeCardRepository = closeCardRepository, + startTangemPayOrderPollingUseCase = startPollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + appCoroutineScope = TestAppCoroutineScope(), + ) + + 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/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 9565030d30..c421579229 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -95,7 +96,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( val status = state.value if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) { val card = state.findCard(initialCard.id, params.initialStatus) ?: return@onEach - if (card.isReissuing) { + if (card.state != TangemPayCardState.Active) { requestHide() } card.displayName?.let { uiState.update(TangemPayCardDetailsUpdateNameTransformer(it)) } @@ -103,7 +104,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( uiState.copy( numberShort = "${StringsSigns.ASTERISK}${card.lastDigits}", cardFrozenState = card.frozenState, - isActionsAvailable = !card.isReissuing, + isActionsAvailable = card.state == TangemPayCardState.Active, ) } subscribeToCardFrozenState(card.id) 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 9db19e4817..a658dbd11a 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 @@ -28,6 +28,7 @@ import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.isFrozen import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayTopUpData @@ -125,7 +126,7 @@ internal class TangemPayCardPageModel @Inject constructor( dailyLimitState = dailyLimitState, settings = buildSettings(card), settingsV2 = buildSettingsV2(card), - isReissueInProgress = card.isReissuing, + isReissueInProgress = card.state == TangemPayCardState.Reissuing, ) } } else { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 640c0c263f..e1510b531d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -30,6 +30,7 @@ import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.components.AddFundsListener @@ -101,7 +102,7 @@ internal class TangemPayDetailsModel @Inject constructor( stateFactory.getInitialState( isTangemPayDeactivated = isTangemPayDeactivated, cardNumberEnd = firstCard?.lastDigits.orEmpty(), - isReissuing = firstCard?.isReissuing ?: false, + isReissuing = firstCard == null || firstCard.state != TangemPayCardState.Active, ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt index c81ee51011..6f3b41750f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model.transformers import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.utils.transformer.Transformer @@ -15,7 +16,7 @@ internal class TangemPayCardDataTransformer( val updatedCard = TangemPayDetailsBalanceBlockState.Card( lastDigits = card.lastDigits, onClick = onCardClick, - isReissuing = card.isReissuing, + isReissuing = card.state != TangemPayCardState.Active, ) val cardsBlockState = prevState.balanceBlockState.cardsBlockState?.copy( cards = persistentListOf(updatedCard), diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index d065d24dec..9808c4f907 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.models.pay.TangemPayCardLimitData import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase @@ -49,7 +50,7 @@ internal class TangemPayCardLimitSetupModelTest { frozenState = TangemPayCardFrozenState.Unfrozen, lastDigits = "1234", limit = null, - isReissuing = false, + state = TangemPayCardState.Active, ) private val initialStatus: AccountStatus.Payment = AccountStatus.Payment( @@ -79,7 +80,7 @@ internal class TangemPayCardLimitSetupModelTest { ) } ), - isReissuing = false, + state = TangemPayCardState.Active, ) val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { every { source } returns StatusSource.ACTUAL diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index f34c06ef04..c771ddd2c9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.utils.converter.Converter @@ -67,7 +68,7 @@ internal class TangemPayMainBlockConverter( is PaymentAccountStatusValue.Loaded -> { val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable TangemPayMainUM.Content( - subtitle = if (card.isReissuing) { + subtitle = if (card.state == TangemPayCardState.Reissuing) { resourceReference(R.string.tangempay_status_replacing) } else { stringReference("*${card.lastDigits}")