Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-30 02:40:27 -07:00
parent 82bbe7ef8e
commit 8b06c1c5c7
10 changed files with 114 additions and 27 deletions

View file

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

View file

@ -3,4 +3,13 @@ package com.tangem.domain.pay.model
data class TangemPayOrderInfo(
val orderId: String,
val orderStatus: OrderStatus,
)
val orderStep: OrderStep = OrderStep.UNKNOWN,
) {
companion object {
fun fromOrder(order: Order) = TangemPayOrderInfo(
orderId = order.id,
orderStatus = order.status,
orderStep = order.step,
)
}
}

View file

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

View file

@ -30,7 +30,11 @@ class CloseTangemPayCardUseCase(
startTangemPayOrderPollingUseCase(
order = order,
userWalletId = userWalletId,
onTerminalReached = { closeCardRepository.removeCloseOrderId(cardId) },
onOrderStateChange = { newOrder ->
if (newOrder.orderStatus.isTerminal) {
closeCardRepository.removeCloseOrderId(cardId)
}
},
)
}
}

View file

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

View file

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

View file

@ -30,7 +30,11 @@ class ReissueTangemPayCardUseCase(
startTangemPayOrderPollingUseCase(
order = order,
userWalletId = userWalletId,
onTerminalReached = { reissueCardRepository.removeReissueOrderId(cardId) },
onOrderStateChange = { newOrder ->
if (newOrder.orderStatus.isTerminal) {
reissueCardRepository.removeReissueOrderId(cardId)
}
},
)
}
}

View file

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

View file

@ -20,15 +20,10 @@ class StartTangemPayOrderPollingUseCase(
*/
private val activeOrders = ConcurrentHashMap.newKeySet<String>()
/**
* @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
}

View file

@ -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<TangemPayOrderInfo>()
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<TangemPayOrderInfo>()
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<String>()
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()
}