Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 08:50:52 -07:00
parent 4fc52e2052
commit 017eece8b7
18 changed files with 938 additions and 14 deletions

View file

@ -40,4 +40,6 @@ interface CustomerOrderRepository {
targetTariffPlanId: String? = null,
transitionType: TangemPayTariffPlanTransition.Type? = null,
): Either<VisaApiError, Order>
suspend fun cancelOrder(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, Unit>
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
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.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
class CancelTangemPayOrderUseCase(
private val customerOrderRepository: CustomerOrderRepository,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
) {
suspend operator fun invoke(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, Unit> = either {
customerOrderRepository.cancelOrder(userWalletId, orderId).bind()
paymentAccountStatusFetcher.invoke(userWalletId)
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = orderId, orderStatus = OrderStatus.PROCESSING),
userWalletId = userWalletId,
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.pay.util.OrderResolver
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
import java.util.UUID
class CreateTariffPlanTransitionOrderUseCase(
private val customerOrderRepository: CustomerOrderRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
targetTariffPlanId: String,
transitionType: TangemPayTariffPlanTransition.Type,
): Either<VisaApiError, Unit> = either {
val orderType = OrderType.TARIFF_PLAN_TRANSITION
val activeOrders = customerOrderRepository.findOrders(
userWalletId = userWalletId,
types = setOf(orderType),
statuses = OrderStatus.activeStatuses,
).bind()
val planTransitionOrder = OrderResolver.selectActive(
orders = activeOrders,
type = orderType,
)
val order = planTransitionOrder ?: customerOrderRepository.createOrder(
userWalletId = userWalletId,
type = orderType,
specificationName = null,
idempotencyKey = UUID.randomUUID().toString(),
targetTariffPlanId = targetTariffPlanId,
transitionType = transitionType,
).bind()
issueCardRepository.storeIssueOrderId(userWalletId = userWalletId, orderId = order.id)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) },
)
}
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
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.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class CancelTangemPayOrderUseCaseTest {
private val customerOrderRepository: CustomerOrderRepository = mockk()
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk()
private val useCase = CancelTangemPayOrderUseCase(
customerOrderRepository = customerOrderRepository,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
)
@Test
fun `GIVEN cancelOrder fails WHEN invoke THEN returns Left and skips fetch and polling`() = runTest {
// GIVEN
coEvery { customerOrderRepository.cancelOrder(USER_WALLET_ID, ORDER_ID) } returns VisaApiError.Unspecified.left()
// WHEN
val result = useCase(USER_WALLET_ID, ORDER_ID)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any(), any()) }
}
@Test
fun `GIVEN cancelOrder succeeds WHEN invoke THEN fetches status and starts polling with PROCESSING order`() =
runTest {
// GIVEN
coEvery { customerOrderRepository.cancelOrder(USER_WALLET_ID, ORDER_ID) } returns Unit.right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
coEvery { startTangemPayOrderPollingUseCase(any(), any(), any()) } returns true
// WHEN
val result = useCase(USER_WALLET_ID, ORDER_ID)
// THEN
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(
TangemPayOrderInfo(orderId = ORDER_ID, orderStatus = OrderStatus.PROCESSING),
USER_WALLET_ID,
any(),
)
}
}
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val ORDER_ID = "order-test-1"
}
}

View file

@ -0,0 +1,144 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
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.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class CreateTariffPlanTransitionOrderUseCaseTest {
private val customerOrderRepository: CustomerOrderRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = CreateTariffPlanTransitionOrderUseCase(
customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = TestAppCoroutineScope(),
)
@Test
fun `GIVEN findOrders fails WHEN invoke THEN returns Left and skips create and store`() = runTest {
// GIVEN
coEvery {
customerOrderRepository.findOrders(USER_WALLET_ID, ACTIVE_TRANSITION_TYPES, OrderStatus.activeStatuses)
} returns VisaApiError.Unspecified.left()
// WHEN
val result = useCase(USER_WALLET_ID, TARGET_PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) {
customerOrderRepository.createOrder(any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
}
@Test
fun `GIVEN active transition order exists WHEN invoke THEN reuses it without calling createOrder`() = runTest {
// GIVEN
val existing = order(id = "existing", status = OrderStatus.PROCESSING)
coEvery {
customerOrderRepository.findOrders(USER_WALLET_ID, ACTIVE_TRANSITION_TYPES, OrderStatus.activeStatuses)
} returns listOf(existing).right()
// WHEN
val result = useCase(USER_WALLET_ID, TARGET_PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE)
// THEN
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) {
customerOrderRepository.createOrder(any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(USER_WALLET_ID, existing.id) }
}
@Test
fun `GIVEN no active order AND createOrder fails WHEN invoke THEN returns Left and skips store`() = runTest {
// GIVEN
coEvery {
customerOrderRepository.findOrders(USER_WALLET_ID, ACTIVE_TRANSITION_TYPES, OrderStatus.activeStatuses)
} returns emptyList<Order>().right()
coEvery {
customerOrderRepository.createOrder(
userWalletId = USER_WALLET_ID,
type = OrderType.TARIFF_PLAN_TRANSITION,
specificationName = null,
targetTariffPlanId = TARGET_PLAN_ID,
transitionType = TangemPayTariffPlanTransition.Type.UPGRADE,
idempotencyKey = any(),
)
} returns VisaApiError.Unspecified.left()
// WHEN
val result = useCase(USER_WALLET_ID, TARGET_PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
}
@Test
fun `GIVEN no active order AND createOrder succeeds WHEN invoke THEN stores the new order id`() = runTest {
// GIVEN
val newOrder = order(id = "new", status = OrderStatus.NEW)
coEvery {
customerOrderRepository.findOrders(USER_WALLET_ID, ACTIVE_TRANSITION_TYPES, OrderStatus.activeStatuses)
} returns emptyList<Order>().right()
coEvery {
customerOrderRepository.createOrder(
userWalletId = USER_WALLET_ID,
type = OrderType.TARIFF_PLAN_TRANSITION,
specificationName = null,
targetTariffPlanId = TARGET_PLAN_ID,
transitionType = TangemPayTariffPlanTransition.Type.UPGRADE,
idempotencyKey = any(),
)
} returns newOrder.right()
// WHEN
val result = useCase(USER_WALLET_ID, TARGET_PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE)
// THEN
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(USER_WALLET_ID, newOrder.id) }
}
private fun order(id: String, status: OrderStatus): Order = Order(
id = id,
customerId = "customer",
type = OrderType.TARIFF_PLAN_TRANSITION,
status = status,
step = OrderStep.UNKNOWN,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
toTariffPlanId = TARGET_PLAN_ID,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val TARGET_PLAN_ID = "plan-plus"
val ACTIVE_TRANSITION_TYPES = setOf(OrderType.TARIFF_PLAN_TRANSITION)
}
}

View file

@ -0,0 +1,174 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
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.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class GetTangemPayTariffPlanStateUseCaseTest {
private val customerOrderRepository: CustomerOrderRepository = mockk()
private val getTariffPlanTransitions: GetTangemPayTariffPlanTransitionsUseCase = mockk()
private val useCase = GetTangemPayTariffPlanStateUseCase(
customerOrderRepository = customerOrderRepository,
getTariffPlanTransitions = getTariffPlanTransitions,
)
@Test
fun `GIVEN no active transition order WHEN invoke THEN state has no order`() = runTest {
// GIVEN
coEvery { customerOrderRepository.findOrders(USER_WALLET_ID, TRANSITION_TYPES, ACTIVE) } returns
emptyList<Order>().right()
// WHEN
val result = useCase(USER_WALLET_ID, CUSTOMER_TARIFF)
// THEN
assertThat(result).isEqualTo(TangemPayTariffPlanState(tariff = CUSTOMER_TARIFF, order = null))
}
@Test
fun `GIVEN findOrders fails WHEN invoke THEN state has no order`() = runTest {
// GIVEN
coEvery { customerOrderRepository.findOrders(USER_WALLET_ID, TRANSITION_TYPES, ACTIVE) } returns
VisaApiError.Unspecified.left()
// WHEN
val result = useCase(USER_WALLET_ID, CUSTOMER_TARIFF)
// THEN
assertThat(result).isEqualTo(TangemPayTariffPlanState(tariff = CUSTOMER_TARIFF, order = null))
}
@Test
fun `GIVEN active order with non-awaiting step WHEN invoke THEN order step is Unknown`() = runTest {
// GIVEN
val order = order(step = OrderStep.UNKNOWN)
coEvery { customerOrderRepository.findOrders(USER_WALLET_ID, TRANSITION_TYPES, ACTIVE) } returns
listOf(order).right()
// WHEN
val result = useCase(USER_WALLET_ID, CUSTOMER_TARIFF)
// THEN
val expected = TangemPayTariffPlanState(
tariff = CUSTOMER_TARIFF,
order = TangemPayTariffPlanState.Order(
orderId = ORDER_ID,
step = TangemPayTariffPlanState.OrderStep.Unknown,
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN awaiting-deposit order but no matching transition WHEN invoke THEN order step is Unknown`() = runTest {
// GIVEN
val order = order(step = OrderStep.AWAITING_DEPOSIT)
coEvery { customerOrderRepository.findOrders(USER_WALLET_ID, TRANSITION_TYPES, ACTIVE) } returns
listOf(order).right()
coEvery { getTariffPlanTransitions(USER_WALLET_ID) } returns emptyList<TangemPayTariffPlanTransition>().right()
// WHEN
val result = useCase(USER_WALLET_ID, CUSTOMER_TARIFF)
// THEN
val expected = TangemPayTariffPlanState(
tariff = CUSTOMER_TARIFF,
order = TangemPayTariffPlanState.Order(
orderId = ORDER_ID,
step = TangemPayTariffPlanState.OrderStep.Unknown,
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN awaiting-deposit order with matching transition WHEN invoke THEN order step is AwaitingDeposit`() =
runTest {
// GIVEN
val order = order(step = OrderStep.AWAITING_DEPOSIT)
val transition = TangemPayTariffPlanTransition(
type = TangemPayTariffPlanTransition.Type.UPGRADE,
plan = TARGET_PLAN,
)
coEvery { customerOrderRepository.findOrders(USER_WALLET_ID, TRANSITION_TYPES, ACTIVE) } returns
listOf(order).right()
coEvery { getTariffPlanTransitions(USER_WALLET_ID) } returns listOf(transition).right()
// WHEN
val result = useCase(USER_WALLET_ID, CUSTOMER_TARIFF)
// THEN
val expected = TangemPayTariffPlanState(
tariff = CUSTOMER_TARIFF,
order = TangemPayTariffPlanState.Order(
orderId = ORDER_ID,
step = TangemPayTariffPlanState.OrderStep.AwaitingDeposit(
fromPlan = CURRENT_PLAN,
toPlan = TARGET_PLAN,
),
),
)
assertThat(result).isEqualTo(expected)
}
private fun order(step: OrderStep): Order = Order(
id = ORDER_ID,
customerId = "customer",
type = OrderType.TARIFF_PLAN_TRANSITION,
status = OrderStatus.PROCESSING,
step = step,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
toTariffPlanId = TARGET_PLAN_ID,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val ORDER_ID = "order-test-1"
const val TARGET_PLAN_ID = "plan-plus"
val TRANSITION_TYPES = setOf(OrderType.TARIFF_PLAN_TRANSITION)
val ACTIVE = OrderStatus.activeStatuses
val CURRENT_PLAN = TangemPayTariffPlan(
id = "plan-basic",
type = TangemPayTariffPlan.Type.BASIC,
name = "Basic",
descriptionItems = emptyList(),
)
val TARGET_PLAN = TangemPayTariffPlan(
id = TARGET_PLAN_ID,
type = TangemPayTariffPlan.Type.PLUS,
name = "Plus",
descriptionItems = emptyList(),
)
val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.ACTIVE,
plan = CURRENT_PLAN,
nextBillingAt = null,
pendingPlan = null,
pendingTransitionAt = null,
)
}
}