Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 11:04:50 +03:00
parent 47f1ccb854
commit 8a83664e9a
19 changed files with 218 additions and 39 deletions

View file

@ -10,7 +10,9 @@ interface TangemPayCloseCardRepository {
suspend fun closeCard(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, TangemPayOrderInfo>
suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit>
suspend fun storeCloseOrderId(cardId: String, orderId: String): Either<UniversalError, Unit>
suspend fun removeCloseOrderId(cardId: String): Either<UniversalError, Unit>
suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?>
}

View file

@ -15,5 +15,7 @@ interface TangemPayReissueCardRepository {
suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit>
suspend fun removeReissueOrderId(cardId: String): Either<UniversalError, Unit>
suspend fun getReissueOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?>
}

View file

@ -23,11 +23,15 @@ class CloseTangemPayCardUseCase(
raise(VisaApiError.Unspecified)
}
closeCardRepository.setCloseOrderId(cardId, order.orderId)
closeCardRepository.storeCloseOrderId(cardId, order.orderId)
paymentAccountStatusFetcher.invoke(userWalletId)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(order, userWalletId)
startTangemPayOrderPollingUseCase(
order = order,
userWalletId = userWalletId,
onTerminalReached = { closeCardRepository.removeCloseOrderId(cardId) },
)
}
}
}

View file

@ -79,6 +79,7 @@ class IssueAdditionalCardUseCase(
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) },
)
}
}

View file

@ -27,7 +27,11 @@ class ReissueTangemPayCardUseCase(
paymentAccountStatusFetcher.invoke(userWalletId)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(order, userWalletId)
startTangemPayOrderPollingUseCase(
order = order,
userWalletId = userWalletId,
onTerminalReached = { reissueCardRepository.removeReissueOrderId(cardId) },
)
}
}
}

View file

@ -56,6 +56,7 @@ class RestoreActiveIssueOrdersUseCase(
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) },
)
}
}

View file

@ -20,7 +20,16 @@ class StartTangemPayOrderPollingUseCase(
*/
private val activeOrders = ConcurrentHashMap.newKeySet<String>()
suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean {
/**
* @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,
): 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}"
@ -35,6 +44,7 @@ class StartTangemPayOrderPollingUseCase(
}
if (newOrder != null && newOrder.orderStatus.isTerminal) {
onTerminalReached?.invoke()
paymentAccountStatusFetcher.invoke(userWalletId)
return newOrder.orderStatus == OrderStatus.COMPLETED
}

View file

@ -38,7 +38,7 @@ internal class ChangeCardFrozenStateUseCaseTest {
cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending)
cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen)
}
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any(), any()) }
}
@Test
@ -56,7 +56,7 @@ internal class ChangeCardFrozenStateUseCaseTest {
cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending)
cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen)
}
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any(), any()) }
}
@Test
@ -65,7 +65,7 @@ internal class ChangeCardFrozenStateUseCaseTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED)
coEvery { cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
coEvery { startPollingUseCase(order, USER_WALLET_ID, any()) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true)
@ -82,7 +82,7 @@ internal class ChangeCardFrozenStateUseCaseTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED)
coEvery { cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
coEvery { startPollingUseCase(order, USER_WALLET_ID, any()) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false)

View file

@ -31,9 +31,9 @@ internal class CloseTangemPayCardUseCaseTest {
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) }
coVerify(exactly = 0) { closeCardRepository.storeCloseOrderId(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any(), any()) }
}
@Test
@ -46,9 +46,9 @@ internal class CloseTangemPayCardUseCaseTest {
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) }
coVerify(exactly = 0) { closeCardRepository.storeCloseOrderId(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any(), any()) }
}
@Test
@ -57,17 +57,17 @@ internal class CloseTangemPayCardUseCaseTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { closeCardRepository.storeCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
coEvery { startPollingUseCase(order, USER_WALLET_ID, any()) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isRight()).isTrue()
coVerifyOrder {
closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID)
closeCardRepository.storeCloseOrderId(CARD_ID, ORDER_ID)
paymentAccountStatusFetcher.invoke(USER_WALLET_ID)
startPollingUseCase(order, USER_WALLET_ID)
startPollingUseCase(order, USER_WALLET_ID, any())
}
}
@ -77,16 +77,16 @@ internal class CloseTangemPayCardUseCaseTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED)
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { closeCardRepository.storeCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
coEvery { startPollingUseCase(order, USER_WALLET_ID, any()) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) }
coVerify(exactly = 1) { closeCardRepository.storeCloseOrderId(CARD_ID, ORDER_ID) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
coVerify(exactly = 1) { startPollingUseCase(order, USER_WALLET_ID) }
coVerify(exactly = 1) { startPollingUseCase(order, USER_WALLET_ID, any()) }
}
private fun createUseCase() = CloseTangemPayCardUseCase(

View file

@ -52,10 +52,10 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
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)
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(first.id, first.status), userWalletId, any())
}
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(second.id, second.status), userWalletId)
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(second.id, second.status), userWalletId, any())
}
}
@ -72,7 +72,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any(), any()) }
}
@Test
@ -89,7 +89,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any(), any()) }
}
@Test
@ -105,7 +105,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
// Assert
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any(), any()) }
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(

View file

@ -138,6 +138,49 @@ internal class StartTangemPayOrderPollingUseCaseTest {
firstPoller.cancel()
}
@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.
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
val events = mutableListOf<String>()
coEvery {
cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID)
} returns TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } answers {
events.add("fetch")
Unit.right()
}
// Act
val result = useCase(order, USER_WALLET_ID, onTerminalReached = { events.add("clear") })
// Assert
assertThat(result).isTrue()
assertThat(events).containsExactly("clear", "fetch").inOrder()
}
@Test
fun `GIVEN order already being polled WHEN invoke again THEN onTerminalReached 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
// 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 })
// Assert — the duplicate is a no-op: it must not clear the hint of the live poller.
assertThat(secondResult).isFalse()
assertThat(duplicateCleared).isFalse()
firstPoller.cancel()
}
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val ORDER_ID = "order-test-1"