Updated on 2026-08-14

This commit is contained in:
Tangem 2026-08-04 02:56:26 -07:00
parent 50a384f13d
commit 457e8870a4
7 changed files with 165 additions and 10 deletions

View file

@ -604,13 +604,25 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private suspend fun buildIssuingCards(userWalletId: UserWalletId): List<TangemPayCard> {
val orderIds = issueCardRepository.getIssueOrderIds(userWalletId)
return orderIds.mapNotNull { orderId ->
val order = cardDetailsRepository.getOrderInfo(userWalletId, orderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
cardDetailsRepository.getOrderInfo(userWalletId, orderId).fold(
ifLeft = { error ->
if (error == VisaApiError.OrderNotFound) {
logger.i("buildIssuingCards $userWalletId: dropping missing order $orderId")
issueCardRepository.removeIssueOrderId(userWalletId, orderId)
null
} else {
issuingPlaceholderCard(orderId)
}
},
ifRight = { order ->
if (order.orderStatus.isTerminal) {
issueCardRepository.removeIssueOrderId(userWalletId, orderId)
null
} else {
issuingPlaceholderCard(orderId)
}
},
)
}
}

View file

@ -308,6 +308,8 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
): Either<UniversalError, TangemPayOrderInfo> = either {
val order = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getOrder(authHeader, orderId)
}.mapLeft { error ->
if (error is VisaApiError.NotFound) VisaApiError.OrderNotFound else error
}.bind()
val result = order.result ?: raise(VisaApiError.Unspecified)

View file

@ -769,5 +769,29 @@ internal class DefaultPaymentAccountStatusFetcherTest {
assertThat(loaded.cards).hasSize(1)
assertThat(loaded.cards.single().state).isEqualTo(TangemPayCardState.Issuing)
}
@Test
fun `GIVEN local issue order missing on backend WHEN invoke THEN placeholder dropped and order forgotten`() =
runTest {
// GIVEN
val customerInfo = buildCustomerInfo()
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_gone")
coEvery {
cardDetailsRepository.getOrderInfo(userWalletId, "order_gone")
} returns VisaApiError.OrderNotFound.left()
coEvery { issueCardRepository.removeIssueOrderId(userWalletId, "order_gone") } just Runs
val storedStatuses = captureStoredStatuses()
// WHEN
fetcher.invoke(params)
// THEN
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.cards.map { it.state }).doesNotContain(TangemPayCardState.Issuing)
coVerify(exactly = 1) { issueCardRepository.removeIssueOrderId(userWalletId, "order_gone") }
}
}
}

View file

@ -5,6 +5,8 @@ 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 com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
@ -13,6 +15,8 @@ class StartTangemPayOrderPollingUseCase(
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
) {
private val logger = TangemLogger.withTag("StartTangemPayOrderPollingUseCase")
/**
* 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
@ -36,7 +40,7 @@ class StartTangemPayOrderPollingUseCase(
val newOrder = if (order.orderStatus.isTerminal) {
order
} else {
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
getOrderInfo(userWalletId = userWalletId, orderId = order.orderId)
}
if (newOrder != null && newOrder != currentOrder) {
@ -56,7 +60,22 @@ class StartTangemPayOrderPollingUseCase(
}
}
private suspend fun getOrderInfo(userWalletId: UserWalletId, orderId: String): TangemPayOrderInfo? {
return cardDetailsRepository.getOrderInfo(userWalletId, orderId).fold(
ifLeft = { error ->
if (error == VisaApiError.OrderNotFound) {
logger.i("$orderId: does not exist on the backend, resolving as CANCELED")
TangemPayOrderInfo(orderId = orderId, orderStatus = OrderStatus.CANCELED)
} else {
logger.e("$orderId: poll failed, will retry — $error")
null
}
},
ifRight = { it },
)
}
companion object {
private const val POLLING_DELAY = 3000L
private const val POLLING_DELAY = 5_000L
}
}

View file

@ -230,6 +230,65 @@ internal class StartTangemPayOrderPollingUseCaseTest {
firstPoller.cancel()
}
@Test
fun `GIVEN processing order WHEN backend reports order not found THEN resolves as CANCELED and stops`() = runTest {
// GIVEN
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
val changes = mutableListOf<TangemPayOrderInfo>()
coEvery {
cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID)
} returns VisaApiError.OrderNotFound.left()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
// WHEN
val result = useCase(order, USER_WALLET_ID, onOrderStateChange = { changes.add(it) })
// THEN
assertThat(result).isFalse()
assertThat(changes).containsExactly(TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED))
coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
}
@Test
fun `GIVEN long outage WHEN backend recovers THEN order is still polled to terminal`() = runTest {
// GIVEN
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
val outage = List(size = 100) { VisaApiError.ServerUnavailable.left() }
coEvery {
cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID)
} returnsMany outage + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
// WHEN
val result = useCase(order, USER_WALLET_ID)
// THEN
assertThat(result).isTrue()
coVerify(exactly = 101) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) }
}
@Test
fun `GIVEN non-terminal order WHEN polling THEN polls at a flat 5s cadence`() = runTest {
// GIVEN
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
coEvery {
cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID)
} returnsMany listOf(
TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW).right(),
VisaApiError.ServerUnavailable.left(),
TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(),
)
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
// WHEN
val result = useCase(order, USER_WALLET_ID)
// THEN
assertThat(result).isTrue()
assertThat(testScheduler.currentTime).isEqualTo(10_000L)
}
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val ORDER_ID = "order-test-1"

View file

@ -46,6 +46,7 @@ import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.*
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanSource
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tokendetails.ExpressTransactionsEvent
import com.tangem.features.tokendetails.ExpressTransactionsEventListener
@ -151,6 +152,12 @@ internal class TangemPayDetailsModel @Inject constructor(
is PaymentAccountStatusValue.Inactive -> uiState.update {
stateFactory.getInactiveState(state)
}
is PaymentAccountStatusValue.AwaitingPlanSelection -> router.replaceAll(
TangemPayAccountDetailsInnerRoute.SelectPlan(
tariffPlan = state.tariffPlan,
source = TangemPaySelectPlanSource.TIERS_ONBOARDING,
),
)
else -> uiState.update { stateFactory.getLoadingState() }
}
}

View file

@ -4,9 +4,11 @@ import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
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.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute
@ -18,6 +20,8 @@ import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.addFundsButton
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanSource
import com.tangem.features.tangempay.tangemPayCard
import com.tangem.features.tangempay.withdrawButton
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -44,6 +48,7 @@ internal class TangemPayDetailsModelTest {
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
private val router: Router = mockk(relaxed = true)
@ParameterizedTest
@MethodSource("provideMutedCases")
@ -114,6 +119,33 @@ internal class TangemPayDetailsModelTest {
model.onDestroy()
}
@Test
fun `GIVEN awaiting plan selection WHEN model created THEN inner stack is replaced with plan selection`() =
runTest {
// GIVEN
val tariffPlan: TangemPayCustomerTariffPlan = mockk(relaxed = true)
val awaitingPlanSelection = PaymentAccountStatusValue.AwaitingPlanSelection(
source = StatusSource.ACTUAL,
tariffPlan = tariffPlan,
)
// WHEN
val model = createModel(testScope = this, statusValue = awaitingPlanSelection)
advanceUntilIdle()
// THEN
verify(exactly = 1) {
router.replaceAll(
TangemPayAccountDetailsInnerRoute.SelectPlan(
tariffPlan = tariffPlan,
source = TangemPaySelectPlanSource.TIERS_ONBOARDING,
),
)
}
verify(exactly = 0) { router.push(any(), any()) }
model.onDestroy()
}
private fun createModel(
testScope: TestScope,
statusSource: StatusSource = StatusSource.ACTUAL,
@ -164,7 +196,7 @@ internal class TangemPayDetailsModelTest {
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
analytics = analytics,
router = mockk(relaxed = true),
router = router,
urlOpener = mockk(relaxed = true),
cardDetailsRepository = cardDetailsRepository,
getBalanceHidingSettingsUseCase = mockk(relaxed = true),