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 7d49c48a8b..10cf570daf 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 @@ -604,13 +604,25 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private suspend fun buildIssuingCards(userWalletId: UserWalletId): List { val orderIds = issueCardRepository.getIssueOrderIds(userWalletId) return orderIds.mapNotNull { orderId -> - val order = cardDetailsRepository.getOrderInfo(userWalletId, orderId).getOrNull() - if (order != null && order.orderStatus.isTerminal) { - issueCardRepository.removeIssueOrderId(userWalletId, orderId) - null - } else { - issuingPlaceholderCard(orderId) - } + 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) + } + }, + ) } } 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 3ae22b574f..adfd7f27aa 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 @@ -308,6 +308,8 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( ): Either = 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) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt index 2c3fc07703..66e5576431 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -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") } + } } } \ 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 index f098ceff63..377c734535 100644 --- 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 @@ -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 } } \ 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 index 9aa8086480..f43f347dcf 100644 --- 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 @@ -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() + 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" 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 ed1d482bed..df36032082 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 @@ -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() } } } diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt index 0a44652574..71906eab03 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -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),