Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-15 05:44:29 -07:00
parent 8a749438bf
commit 8098476fde
15 changed files with 459 additions and 180 deletions

View file

@ -9,6 +9,10 @@ android {
namespace = "com.tangem.domain.visa"
}
tasks.withType<Test>().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)
}

View file

@ -5,4 +5,7 @@ enum class OrderStatus {
PROCESSING,
COMPLETED,
CANCELED,
}
}
val OrderStatus.isFinalStatus
get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED

View file

@ -1,6 +1,6 @@
package com.tangem.domain.pay.model
data class TangemPayReissueOrderInfo(
data class TangemPayOrderInfo(
val orderId: String,
val orderStatus: OrderStatus,
)

View file

@ -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<UniversalError, Unit>
suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either<UniversalError, TangemPayCardFrozenState>
suspend fun unfreezeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardFrozenState>
suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either<UniversalError, TangemPayOrderInfo>
suspend fun unfreezeCard(userWalletId: UserWalletId, cardId: String): Either<UniversalError, TangemPayOrderInfo>
fun cardFrozenState(cardId: String): Flow<TangemPayCardFrozenState>
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<UniversalError, Unit>
suspend fun getOrderInfo(userWalletId: UserWalletId, orderId: String): Either<UniversalError, TangemPayOrderInfo>
}

View file

@ -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<VisaApiError, TangemPayReissueCardFee>
suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, TangemPayReissueOrderInfo>
suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, TangemPayOrderInfo>
suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit>
suspend fun getReissueOrderInfo(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayReissueOrderInfo?>
): Either<UniversalError, TangemPayOrderInfo?>
}

View file

@ -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<UniversalError, Unit> {
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)
}
}
}

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}