From 8b06c1c5c768dcfad3f45b7be2692cb1a6e3526e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Jul 2026 02:40:27 -0700 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultTangemPayCardDetailsRepository.kt | 2 + .../domain/pay/model/TangemPayOrderInfo.kt | 11 ++- .../usecase/CancelTangemPayOrderUseCase.kt | 5 +- .../pay/usecase/CloseTangemPayCardUseCase.kt | 6 +- .../CreateTariffPlanTransitionOrderUseCase.kt | 12 +++- .../pay/usecase/IssueAdditionalCardUseCase.kt | 8 ++- .../usecase/ReissueTangemPayCardUseCase.kt | 6 +- .../RestoreActiveIssueOrdersUseCase.kt | 8 ++- .../StartTangemPayOrderPollingUseCase.kt | 14 ++-- .../StartTangemPayOrderPollingUseCaseTest.kt | 69 ++++++++++++++++--- 10 files changed, 114 insertions(+), 27 deletions(-) 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 62be79cd12..3ae22b574f 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 @@ -24,6 +24,7 @@ import com.tangem.data.pay.store.TangemPayStorage import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.OrderStep import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails @@ -319,6 +320,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( Status.COMPLETED -> OrderStatus.COMPLETED Status.CANCELED -> OrderStatus.CANCELED }, + orderStep = OrderStep.fromString(result.step), ) } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt index eebc510bd5..50e5df061d 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt @@ -3,4 +3,13 @@ package com.tangem.domain.pay.model data class TangemPayOrderInfo( val orderId: String, val orderStatus: OrderStatus, -) \ No newline at end of file + val orderStep: OrderStep = OrderStep.UNKNOWN, +) { + companion object { + fun fromOrder(order: Order) = TangemPayOrderInfo( + orderId = order.id, + orderStatus = order.status, + orderStep = order.step, + ) + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTangemPayOrderUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTangemPayOrderUseCase.kt index 9685d410aa..330e30821b 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTangemPayOrderUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTangemPayOrderUseCase.kt @@ -20,7 +20,10 @@ class CancelTangemPayOrderUseCase( paymentAccountStatusFetcher.invoke(userWalletId) startTangemPayOrderPollingUseCase( - order = TangemPayOrderInfo(orderId = orderId, orderStatus = OrderStatus.PROCESSING), + order = TangemPayOrderInfo( + orderId = orderId, + orderStatus = OrderStatus.PROCESSING, + ), userWalletId = userWalletId, ) } 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 index e193105082..6a7f90105c 100644 --- 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 @@ -30,7 +30,11 @@ class CloseTangemPayCardUseCase( startTangemPayOrderPollingUseCase( order = order, userWalletId = userWalletId, - onTerminalReached = { closeCardRepository.removeCloseOrderId(cardId) }, + onOrderStateChange = { newOrder -> + if (newOrder.orderStatus.isTerminal) { + closeCardRepository.removeCloseOrderId(cardId) + } + }, ) } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCase.kt index 401db0c641..7c7e0b7d4a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.account.TangemPayTariffPlanTransition 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.OrderStep import com.tangem.domain.pay.model.OrderType import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.CustomerOrderRepository @@ -54,9 +55,16 @@ class CreateTariffPlanTransitionOrderUseCase( appCoroutineScope.launch { startTangemPayOrderPollingUseCase( - order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status), + order = TangemPayOrderInfo.fromOrder(order), userWalletId = userWalletId, - onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) }, + onOrderStateChange = { newOrder -> + if (newOrder.orderStatus.isTerminal) { + issueCardRepository.removeIssueOrderId(userWalletId, order.id) + } + if (newOrder.orderStep == OrderStep.AWAITING_DEPOSIT) { + paymentAccountStatusFetcher.invoke(userWalletId) + } + }, ) } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt index 1c115ed53c..79d0f6b556 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt @@ -77,9 +77,13 @@ class IssueAdditionalCardUseCase( appCoroutineScope.launch { startTangemPayOrderPollingUseCase( - order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status), + order = TangemPayOrderInfo.fromOrder(order), userWalletId = userWalletId, - onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) }, + onOrderStateChange = { newOrder -> + if (newOrder.orderStatus.isTerminal) { + issueCardRepository.removeIssueOrderId(userWalletId, order.id) + } + }, ) } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt index 88dda725e3..4b43ec102b 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt @@ -30,7 +30,11 @@ class ReissueTangemPayCardUseCase( startTangemPayOrderPollingUseCase( order = order, userWalletId = userWalletId, - onTerminalReached = { reissueCardRepository.removeReissueOrderId(cardId) }, + onOrderStateChange = { newOrder -> + if (newOrder.orderStatus.isTerminal) { + reissueCardRepository.removeReissueOrderId(cardId) + } + }, ) } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt index 32439c2536..860b5e6687 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt @@ -54,9 +54,13 @@ class RestoreActiveIssueOrdersUseCase( appCoroutineScope.launch { startTangemPayOrderPollingUseCase( - order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status), + order = TangemPayOrderInfo.fromOrder(order), userWalletId = userWalletId, - onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) }, + onOrderStateChange = { newOrder -> + if (newOrder.orderStatus.isTerminal) { + issueCardRepository.removeIssueOrderId(userWalletId, order.id) + } + }, ) } } 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 e1f61bf615..f098ceff63 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 @@ -20,15 +20,10 @@ class StartTangemPayOrderPollingUseCase( */ private val activeOrders = ConcurrentHashMap.newKeySet() - /** - * @param onTerminalReached invoked once the order is terminal, **before** the status refresh. Callers - * use it to forget the locally stored order-id hint (issue / reissue / close) so the refresh does not - * re-issue a `GET /order/{id}` for the order that was just resolved. - */ suspend operator fun invoke( order: TangemPayOrderInfo, userWalletId: UserWalletId, - onTerminalReached: (suspend () -> Unit)? = null, + onOrderStateChange: (suspend (TangemPayOrderInfo) -> Unit)? = null, ): 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. @@ -36,6 +31,7 @@ class StartTangemPayOrderPollingUseCase( if (!activeOrders.add(key)) return false try { + var currentOrder: TangemPayOrderInfo? = null while (true) { val newOrder = if (order.orderStatus.isTerminal) { order @@ -43,8 +39,12 @@ class StartTangemPayOrderPollingUseCase( cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull() } + if (newOrder != null && newOrder != currentOrder) { + currentOrder = newOrder + onOrderStateChange?.invoke(newOrder) + } + if (newOrder != null && newOrder.orderStatus.isTerminal) { - onTerminalReached?.invoke() paymentAccountStatusFetcher.invoke(userWalletId) return newOrder.orderStatus == OrderStatus.COMPLETED } 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 e8f3d9157b..9aa8086480 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 @@ -6,6 +6,7 @@ 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.OrderStep import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.visa.error.VisaApiError @@ -139,9 +140,52 @@ internal class StartTangemPayOrderPollingUseCaseTest { } @Test - fun `GIVEN processing order WHEN poll returns terminal THEN onTerminalReached runs before status fetch`() = runTest { - // Arrange — recording both callbacks proves the order hint is cleared before the refresh that - // would otherwise re-poll GET /order/{id} for the just-resolved order. + fun `GIVEN processing order WHEN step changes while non-terminal THEN onOrderStateChange gets every new order`() = + runTest { + // Arrange — a step-only transition (PROCESSING/AWAITING_DEPOSIT) must be reported, which is only + // observable because the step is part of the order identity. + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + val awaitingDeposit = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING, OrderStep.AWAITING_DEPOSIT) + val completed = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + val changes = mutableListOf() + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf(awaitingDeposit.right(), completed.right()) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + // Act + val result = useCase(order, USER_WALLET_ID, onOrderStateChange = { changes.add(it) }) + + // Assert — the terminal state is reported as well, so callers see the whole transition chain. + assertThat(result).isTrue() + assertThat(changes).containsExactly(awaitingDeposit, completed).inOrder() + } + + @Test + fun `GIVEN order already in target step WHEN poll returns the same step THEN onOrderStateChange still reports it`() = + runTest { + // Arrange — a restored order can already sit in AWAITING_DEPOSIT; the first poll then returns a + // value equal to the incoming one and must still be reported. + val awaitingDeposit = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING, OrderStep.AWAITING_DEPOSIT) + val completed = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + val changes = mutableListOf() + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf(awaitingDeposit.right(), completed.right()) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + // Act + val result = useCase(awaitingDeposit, USER_WALLET_ID, onOrderStateChange = { changes.add(it) }) + + // Assert + assertThat(result).isTrue() + assertThat(changes).containsExactly(awaitingDeposit, completed).inOrder() + } + + @Test + fun `GIVEN processing order WHEN poll returns terminal THEN terminal is reported before status fetch`() = runTest { + // Arrange — recording both the callback and the fetch proves callers can clear the order hint before + // the refresh that would otherwise re-issue GET /order/{id} for the just-resolved order. val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) val events = mutableListOf() coEvery { @@ -153,30 +197,35 @@ internal class StartTangemPayOrderPollingUseCaseTest { } // Act - val result = useCase(order, USER_WALLET_ID, onTerminalReached = { events.add("clear") }) + val result = useCase( + order = order, + userWalletId = USER_WALLET_ID, + onOrderStateChange = { events.add(if (it.orderStatus.isTerminal) "terminal" else "changed") }, + ) // Assert assertThat(result).isTrue() - assertThat(events).containsExactly("clear", "fetch").inOrder() + assertThat(events).containsExactly("terminal", "fetch").inOrder() } @Test - fun `GIVEN order already being polled WHEN invoke again THEN onTerminalReached is not invoked for duplicate`() = + fun `GIVEN order already being polled WHEN invoke again THEN onOrderStateChange is not invoked for duplicate`() = runTest { // Arrange — first poller never reaches terminal, 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() - var duplicateCleared = false + var duplicateNotified = false // Act — start the first poller, then invoke again for the same order. val firstPoller = launch { useCase(order, USER_WALLET_ID) } runCurrent() - val secondResult = useCase(order, USER_WALLET_ID, onTerminalReached = { duplicateCleared = true }) + val secondResult = useCase(order, USER_WALLET_ID, onOrderStateChange = { duplicateNotified = true }) - // Assert — the duplicate is a no-op: it must not clear the hint of the live poller. + // Assert — the duplicate is a no-op: it must not report state for the live poller, otherwise the + // caller would clear the hint of that poller and hide the in-flight order from the status. assertThat(secondResult).isFalse() - assertThat(duplicateCleared).isFalse() + assertThat(duplicateNotified).isFalse() firstPoller.cancel() }