Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-23 15:29:05 +05:00
parent 0ab5d41ba0
commit ccb9aa9110
9 changed files with 272 additions and 119 deletions

View file

@ -255,10 +255,18 @@ internal interface TangemPayDataModule {
}
@Provides
fun provideRestoreActiveOrdersUseCase(
fun provideRestoreActiveIssueOrdersUseCase(
customerOrderRepository: CustomerOrderRepository,
): RestoreActiveOrdersUseCase {
return RestoreActiveOrdersUseCase(customerOrderRepository)
issueCardRepository: TangemPayIssueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
): RestoreActiveIssueOrdersUseCase {
return RestoreActiveIssueOrdersUseCase(
customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
)
}
@Provides

View file

@ -0,0 +1,68 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
/**
* Restores in-flight card-issuance orders on app launch / when returning to the wallet screen.
*
* `findOrders` is the source of truth a locally stored order id is only a hint that does not
* survive a force close. This use case re-discovers the active issue orders, persists their ids so
* the payment-account state renders an "issuing" placeholder card, and (re)starts polling so the
* placeholder is driven to its terminal state.
*
* Non-fatal exceptions are logged and collapsed to [VisaApiError.Unspecified]; the caller treats the
* result as fire-and-forget.
*
* @property customerOrderRepository source of truth for active orders.
* @property issueCardRepository persists issue-order ids for placeholder rendering.
* @property startTangemPayOrderPollingUseCase drives a restored order to its terminal state.
*/
class RestoreActiveIssueOrdersUseCase(
private val customerOrderRepository: CustomerOrderRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Unit> = either {
val orders = catch(
block = {
customerOrderRepository.findOrders(
userWalletId = userWalletId,
types = OrderType.issueCardTypes,
statuses = OrderStatus.activeStatuses,
).bind()
},
catch = { handleError(it) },
).filter { it.status.isActive }
orders.forEach { order ->
issueCardRepository.storeIssueOrderId(userWalletId = userWalletId, orderId = order.id)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
)
}
}
}
private fun Raise<VisaApiError>.handleError(throwable: Throwable): Nothing {
TangemLogger.e("Error in RestoreActiveIssueOrdersUseCase", throwable)
raise(VisaApiError.Unspecified)
}
}

View file

@ -1,30 +0,0 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
/**
* same customer.
*
* Wraps `findOrders` (the source of truth) and filters to the active set (NEW / PROCESSING).
* The caller decides how to dispatch each order to the appropriate flow.
*/
class RestoreActiveOrdersUseCase(
private val customerOrderRepository: CustomerOrderRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, List<Order>> {
return customerOrderRepository.findOrders(
userWalletId = userWalletId,
statuses = ACTIVE_STATUSES,
)
}
private companion object {
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
}
}

View file

@ -6,12 +6,27 @@ import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
class StartTangemPayOrderPollingUseCase(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
) {
/**
* Order keys (`walletId:orderId`) currently being polled. Keeps polling idempotent so callers that
* may fire repeatedly for the same order (e.g. order restore on every wallet (re)load) never spawn a
* second poller for it.
*/
private val activeOrders = ConcurrentHashMap.newKeySet<String>()
suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean {
// A poller for this exact order is already running — `false` only reaches fire-and-forget issue
// callers (restore / issue-additional); the awaiting freeze caller always polls a fresh order id.
val key = "${userWalletId.stringValue}:${order.orderId}"
if (!activeOrders.add(key)) return false
try {
while (true) {
val newOrder = if (order.orderStatus.isTerminal) {
order
@ -26,6 +41,9 @@ class StartTangemPayOrderPollingUseCase(
delay(POLLING_DELAY)
}
} finally {
activeOrders.remove(key)
}
}
companion object {

View file

@ -0,0 +1,130 @@
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.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class RestoreActiveIssueOrdersUseCaseTest {
private val orderRepository: CustomerOrderRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = RestoreActiveIssueOrdersUseCase(
customerOrderRepository = orderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = TestAppCoroutineScope(),
)
private val userWalletId = UserWalletId("1234567890ABCDEF")
@Test
fun `GIVEN active issue orders WHEN invoke THEN each order is stored and polled`() = runTest {
// Arrange
val first = order(id = "first", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW)
val second = order(id = "second", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING)
coEvery {
orderRepository.findOrders(
userWalletId = userWalletId,
types = ISSUE_ORDER_TYPES,
statuses = ACTIVE_STATUSES,
)
} returns listOf(first, second).right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, first.id) }
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, second.id) }
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(first.id, first.status), userWalletId)
}
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(second.id, second.status), userWalletId)
}
}
@Test
fun `GIVEN no active orders WHEN invoke THEN nothing is stored or polled`() = runTest {
// Arrange
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns emptyList<Order>().right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN a terminal order leaks through WHEN invoke THEN it is filtered out`() = runTest {
// Arrange
val completed = order(id = "done", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.COMPLETED)
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns listOf(completed).right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN findOrders fails WHEN invoke THEN returns Unspecified and stores nothing`() = runTest {
// Arrange
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns VisaApiError.Unspecified.left()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
id = id,
customerId = "customer",
type = type,
status = status,
step = null,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
private companion object {
val ISSUE_ORDER_TYPES = OrderType.issueCardTypes
val ACTIVE_STATUSES = OrderStatus.activeStatuses
}
}

View file

@ -1,72 +0,0 @@
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.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class RestoreActiveOrdersUseCaseTest {
private val repository: CustomerOrderRepository = mockk()
private val useCase = RestoreActiveOrdersUseCase(repository)
private val userWalletId = UserWalletId("1234567890ABCDEF")
@Test
fun `passes only NEW and PROCESSING statuses to findOrders`() = runTest {
val expected = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) } returns
emptyList<Order>().right()
useCase(userWalletId)
coVerify(exactly = 1) { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) }
}
@Test
fun `returns the orders found by the repository`() = runTest {
val orders = listOf(
order(id = "issue", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING),
order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW),
)
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right()
val result = useCase(userWalletId)
assertThat(result.getOrNull()).containsExactlyElementsIn(orders)
}
@Test
fun `surfaces repository errors`() = runTest {
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns
VisaApiError.Unspecified.left()
val result = useCase(userWalletId)
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
id = id,
customerId = "customer",
type = type,
status = status,
step = null,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
}

View file

@ -9,7 +9,11 @@ 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 io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@ -112,6 +116,28 @@ internal class StartTangemPayOrderPollingUseCaseTest {
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
}
@Test
fun `GIVEN order already being polled WHEN invoke again for same order THEN returns false without a second poll`() =
runTest {
// Arrange — first poller never reaches a terminal status, so it keeps polling.
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
coEvery { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } returns
TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING).right()
// Act — start the first poller, let it register the order and park in its poll delay,
// then invoke again for the same order.
val firstPoller = launch { useCase(order, USER_WALLET_ID) }
runCurrent()
val secondResult = useCase(order, USER_WALLET_ID)
// Assert — the duplicate invoke is a no-op (no extra getOrderInfo, no status fetch).
assertThat(secondResult).isFalse()
coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
firstPoller.cancel()
}
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val ORDER_ID = "order-test-1"

View file

@ -28,6 +28,7 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.QrSendTarget
@ -47,7 +48,6 @@ import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWal
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
@ -62,8 +62,8 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejec
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.utils.Provider

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.RestoreActiveIssueOrdersUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletTangemPayAnalyticsEventSender
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
internal class TangemPayMainSubscriber @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val restoreActiveIssueOrdersUseCase: RestoreActiveIssueOrdersUseCase,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val analytics: WalletTangemPayAnalyticsEventSender,
) : WalletSubscriber() {
@ -23,6 +25,9 @@ internal class TangemPayMainSubscriber @AssistedInject constructor(
// TODO: Doston move this logic to proper place(e.g. WalletBalanceFetcher)
tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet)
}
coroutineScope.launch {
restoreActiveIssueOrdersUseCase(userWallet.walletId)
}
subscribeToStatus(coroutineScope)
return emptyFlow<Any>()
}