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

@ -95,6 +95,12 @@ interface TangemPayApi {
@Body body: VirtualAccountOrderRequest,
): ApiResponse<OrderResponse>
@POST("v1/order/{order_id}/cancel")
suspend fun cancelOrder(
@Header("Authorization") authHeader: String,
@Path("order_id") orderId: String,
): ApiResponse<Any>
/** Customer offers — used to gate the issue-additional-card flow. */
@GET("v1/customer/offers")
suspend fun getCustomerOffers(@Header("Authorization") authHeader: String): ApiResponse<CustomerOffersResponse>

View file

@ -130,6 +130,7 @@ internal interface TangemPayDataModule {
repository: DefaultTariffPlanTransitionsRepository,
): TangemPayTariffPlanTransitionsRepository
@Suppress("TooManyFunctions")
companion object {
@Provides
@ -335,5 +336,34 @@ internal interface TangemPayDataModule {
pollingUseCase = pollingUseCase,
)
}
@Provides
fun provideCancelTangemPayOrderUseCase(
customerOrderRepository: CustomerOrderRepository,
issueCardRepository: TangemPayIssueCardRepository,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
): CancelTangemPayOrderUseCase {
return CancelTangemPayOrderUseCase(
customerOrderRepository = customerOrderRepository,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
)
}
@Provides
fun provideCreateTariffPlanTransitionOrderUseCase(
customerOrderRepository: CustomerOrderRepository,
issueCardRepository: TangemPayIssueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
): CreateTariffPlanTransitionOrderUseCase {
return CreateTariffPlanTransitionOrderUseCase(
customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
)
}
}
}

View file

@ -79,4 +79,10 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
OrderConverter.convert(result)
}
}
override suspend fun cancelOrder(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, Unit> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.cancelOrder(authHeader = authHeader, orderId = orderId)
}.map {}
}
}

View file

@ -0,0 +1,187 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class TangemPayTariffPlanConverterTest {
@Test
fun `GIVEN null value WHEN convert THEN returns null`() {
assertThat(TangemPayTariffPlanConverter.convert(null)).isNull()
}
@Test
fun `GIVEN missing id WHEN convert THEN returns null`() {
val value = tariffPlan(id = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN missing name WHEN convert THEN returns null`() {
val value = tariffPlan(name = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN full valid plan WHEN convert THEN maps all fields`() {
// GIVEN
val value = tariffPlan(
type = "PLUS",
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(
type = "PLAN_RELATED",
order = 2,
title = "Title",
body = "Body",
),
),
images = listOf(CustomerMeResponse.Image(type = "MAIN", url = "https://img")),
fees = listOf(
CustomerMeResponse.Fee(
type = "RECURRING",
amount = BigDecimal("9.99"),
currency = "USD",
description = "Monthly",
period = "MONTH",
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
val expected = TangemPayTariffPlan(
id = PLAN_ID,
type = TangemPayTariffPlan.Type.PLUS,
name = PLAN_NAME,
descriptionItems = listOf(
TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.PLAN_RELATED,
order = 2,
title = "Title",
body = "Body",
),
),
images = listOf(
TangemPayTariffPlan.Image(type = TangemPayTariffPlan.Image.Type.MAIN, url = "https://img"),
),
fees = listOf(
TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.RECURRING,
amount = BigDecimal("9.99"),
currency = "USD",
description = "Monthly",
period = TangemPayTariffPlan.Fee.Period.MONTH,
),
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN unknown enum strings and null optional fields WHEN convert THEN falls back to defaults`() {
// GIVEN
val value = tariffPlan(
type = "SOMETHING_NEW",
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(type = "wat", order = null, title = "Title", body = null),
),
images = listOf(CustomerMeResponse.Image(type = null, url = "https://img")),
fees = listOf(
CustomerMeResponse.Fee(
type = null,
amount = BigDecimal.ONE,
currency = null,
description = null,
period = null,
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
val expected = TangemPayTariffPlan(
id = PLAN_ID,
type = TangemPayTariffPlan.Type.UNKNOWN,
name = PLAN_NAME,
descriptionItems = listOf(
TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.UNKNOWN,
order = 0,
title = "Title",
body = "",
),
),
images = listOf(
TangemPayTariffPlan.Image(type = TangemPayTariffPlan.Image.Type.UNKNOWN, url = "https://img"),
),
fees = listOf(
TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.UNKNOWN,
amount = BigDecimal.ONE,
currency = "",
description = "",
period = null,
),
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN nested items with missing required fields WHEN convert THEN filters them out`() {
// GIVEN
val value = tariffPlan(
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(type = "PLAN_RELATED", order = 1, title = null, body = "Body"),
),
images = listOf(CustomerMeResponse.Image(type = "MAIN", url = null)),
fees = listOf(
CustomerMeResponse.Fee(
type = "FREE",
amount = null,
currency = "USD",
description = "d",
period = null,
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
assertThat(result?.descriptionItems).isEmpty()
assertThat(result?.images).isEmpty()
assertThat(result?.fees).isEmpty()
}
private fun tariffPlan(
id: String? = PLAN_ID,
type: String? = "BASIC",
name: String? = PLAN_NAME,
descriptionItems: List<CustomerMeResponse.DescriptionItem>? = null,
images: List<CustomerMeResponse.Image>? = null,
fees: List<CustomerMeResponse.Fee>? = null,
) = CustomerMeResponse.TariffPlan(
id = id,
type = type,
name = name,
descriptionItems = descriptionItems,
images = images,
fees = fees,
)
private companion object {
const val PLAN_ID = "plan-1"
const val PLAN_NAME = "Plus"
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.pay.repository
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionResponse
import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionsResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
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.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultTariffPlanTransitionsRepositoryTest {
private val tangemPayApi: TangemPayApi = mockk()
private val requestHelper: TangemPayRequestPerformer = mockk()
private val repository = DefaultTariffPlanTransitionsRepository(
tangemPayApi = tangemPayApi,
requestHelper = requestHelper,
)
@BeforeEach
fun setUp() {
coEvery {
requestHelper.performRequest<Any>(userWalletId = any(), requestBlock = any())
} coAnswers {
val block = secondArg<suspend (String) -> ApiResponse<Any>>()
when (val response = block(AUTH_HEADER)) {
is ApiResponse.Success -> response.data.right()
is ApiResponse.Error -> VisaApiError.Unspecified.left()
}
}
}
@Test
fun `GIVEN backend error WHEN getTransitions THEN returns error`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<TariffPlanTransitionsResponse>
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
@Test
fun `GIVEN null result WHEN getTransitions THEN returns empty list`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns
ApiResponse.Success(TariffPlanTransitionsResponse(result = null))
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
assertThat(result.getOrNull()).isEqualTo(emptyList<TangemPayTariffPlanTransition>())
}
@Test
fun `GIVEN valid transitions WHEN getTransitions THEN maps them to domain`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns ApiResponse.Success(
TariffPlanTransitionsResponse(
result = listOf(
TariffPlanTransitionResponse(type = "UPGRADE", tariffPlan = tariffPlan()),
),
),
)
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
val expected = listOf(
TangemPayTariffPlanTransition(
type = TangemPayTariffPlanTransition.Type.UPGRADE,
plan = TangemPayTariffPlan(
id = PLAN_ID,
type = TangemPayTariffPlan.Type.PLUS,
name = PLAN_NAME,
descriptionItems = emptyList(),
images = emptyList(),
fees = emptyList(),
),
),
)
assertThat(result.getOrNull()).isEqualTo(expected)
}
@Test
fun `GIVEN a transition with unconvertible plan WHEN getTransitions THEN it is filtered out`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns ApiResponse.Success(
TariffPlanTransitionsResponse(
result = listOf(
TariffPlanTransitionResponse(type = "UPGRADE", tariffPlan = null),
TariffPlanTransitionResponse(type = "DOWNGRADE", tariffPlan = tariffPlan(id = null)),
TariffPlanTransitionResponse(type = "ACTIVATION", tariffPlan = tariffPlan()),
),
),
)
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
val transitions = result.getOrNull().orEmpty()
assertThat(transitions.map { it.type })
.containsExactly(TangemPayTariffPlanTransition.Type.ACTIVATION)
}
private fun tariffPlan(id: String? = PLAN_ID) = CustomerMeResponse.TariffPlan(
id = id,
type = "PLUS",
name = PLAN_NAME,
descriptionItems = null,
images = null,
fees = null,
)
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val AUTH_HEADER = "auth-header"
const val PLAN_ID = "plan-plus"
const val PLAN_NAME = "Plus"
}
}

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

View file

@ -8,11 +8,16 @@ import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.format.bigdecimal.optionalDecimals
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.domain.models.account.PaymentAccountStatusValue
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.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
@ -75,7 +80,7 @@ internal class TangemPayDetailsStateFactory(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = status.tariffPlan?.tariff),
itemsV2 = getTopBarMenuItemsV2(tariffPlan = status.tariffPlan),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -106,15 +111,17 @@ internal class TangemPayDetailsStateFactory(
),
isBalanceHidden = false,
addToWalletBlockState = null,
errorNotificationConfig = when (status.error) {
null -> null
PaymentAccountStatusValue.Error.NotSynced -> createRenewSessionNotificationConfig(isRedesignEnabled)
else -> createAccountUnavailableConfig(isRedesignEnabled)
},
errorNotificationConfig = createErrorConfig(status.error) ?: createAwaitingDepositConfig(status.tariffPlan),
accountDeactivatedNotificationConfig = null,
)
}
private fun createErrorConfig(error: PaymentAccountStatusValue.Error?): NotificationConfig? = when (error) {
null -> null
PaymentAccountStatusValue.Error.NotSynced -> createRenewSessionNotificationConfig(isRedesignEnabled)
else -> createAccountUnavailableConfig(isRedesignEnabled)
}
private fun List<TangemPayCard>.resolveProgressBanner(): CardsProgressBannerUM? = when {
any { it.state == TangemPayCardState.Reissuing } -> CardsProgressBannerUM.Reissuing
any { it.state == TangemPayCardState.Issuing } -> CardsProgressBannerUM.Issuing
@ -167,6 +174,30 @@ internal class TangemPayDetailsStateFactory(
},
)
// TODO v_rodionov: strings hardcoded for now - wait for localization
private fun createAwaitingDepositConfig(tariffPlan: TangemPayTariffPlanState?): NotificationConfig? {
val order = tariffPlan?.order ?: return null
val orderStep = order.step
if (orderStep !is TangemPayTariffPlanState.OrderStep.AwaitingDeposit) return null
val recurringFee = orderStep.toPlan.fees.find { it.type == TangemPayTariffPlan.Fee.Type.RECURRING }
val feeText = recurringFee?.let { fee ->
val currency = getJavaCurrencyByCode(fee.currency)
fee.amount.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }
}
val title = if (feeText != null) "Top-up your account on $feeText" else "Top-up your account"
return NotificationConfig(
title = stringReference(title),
subtitle = stringReference("To pay monthly fee for plan and start use card"),
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = stringReference("Cancel ${orderStep.toPlan.name}, move to ${orderStep.fromPlan.name}"),
onClick = { intents.onCancelPlusTransition(order.orderId) },
),
)
}
private fun createRenewSessionNotificationConfig(isRedesignEnabled: Boolean) = NotificationConfig(
title = resourceReference(R.string.tangempay_sync_needed_title),
subtitle = resourceReference(R.string.tangempay_sync_needed_body),
@ -242,20 +273,18 @@ internal class TangemPayDetailsStateFactory(
}.toImmutableList()
}
private fun getTopBarMenuItemsV2(
tariffPlan: TangemPayCustomerTariffPlan?,
): ImmutableList<TangemPayDropDownItemUM> {
private fun getTopBarMenuItemsV2(tariffPlan: TangemPayTariffPlanState?): ImmutableList<TangemPayDropDownItemUM> {
return buildList {
if (isTiersPlusPlanEnabled && tariffPlan != null) {
add(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangempay_current_plan_title),
onClick = { intents.onClickCurrentPlan(tariffPlan) },
onClick = { intents.onClickCurrentPlan(tariffPlan.tariff) },
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_information_24,
tintReference = { TangemTheme.colors3.icon.primary },
),
subtitle = stringReference(tariffPlan.plan.name),
subtitle = stringReference(tariffPlan.tariff.plan.name),
),
)
}

View file

@ -36,6 +36,7 @@ import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.CancelTangemPayOrderUseCase
import com.tangem.domain.pay.usecase.GetCustomerOffersUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
@ -87,6 +88,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
private val onboardingRepository: OnboardingRepository,
private val getCustomerOffers: GetCustomerOffersUseCase,
private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase,
) : Model(),
TangemPayTxHistoryUiActions,
TangemPayDetailIntents,
@ -170,6 +172,15 @@ internal class TangemPayDetailsModel @Inject constructor(
.launchIn(modelScope)
}
override fun onCancelPlusTransition(orderId: String) {
modelScope.launch {
cancelTangemPayOrderUseCase(userWalletId = userWalletId, orderId = orderId)
.onLeft {
uiMessageSender.send(TangemPayMessagesFactory.createGenericError())
}
}
}
fun onStart() {
onRefreshSwipe(refreshState = ShowRefreshState(false))
}

View file

@ -5,12 +5,16 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.pay.usecase.CreateTariffPlanTransitionOrderUseCase
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanTransitionsUseCase
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -27,6 +31,8 @@ internal class TangemPaySelectPlanModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase,
private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params = paramsContainer.require<TangemPaySelectPlanComponent.Params>()
@ -34,6 +40,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
private var transitions: List<TangemPayTariffPlanTransition> = emptyList()
private var selectedIndex: Int = 0
private var isConfirm: Boolean = false
private var isProcessing: Boolean = false
val state: StateFlow<TangemPaySelectPlanUM>
field = MutableStateFlow(buildState())
@ -73,6 +80,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
}
private fun onBackClick() {
if (isProcessing) return
if (isConfirm) {
isConfirm = false
state.update { buildState() }
@ -81,6 +89,29 @@ internal class TangemPaySelectPlanModel @Inject constructor(
}
}
private fun onConfirmClick() {
val transition = transitions.getOrNull(selectedIndex) ?: return
if (transition.type != TangemPayTariffPlanTransition.Type.UPGRADE) return
if (isProcessing) return
isProcessing = true
state.update { buildState() }
modelScope.launch {
createTransitionOrder(
userWalletId = params.userWalletId,
targetTariffPlanId = transition.plan.id,
transitionType = transition.type,
).fold(
ifRight = { router.popTo(TangemPayAccountDetailsInnerRoute.AccountDetails) },
ifLeft = {
isProcessing = false
state.update { buildState() }
uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError())
},
)
}
}
private fun buildState(showPlanCompare: Boolean = false): TangemPaySelectPlanUM = TangemPaySelectPlanUM(
topBarTitle = if (isConfirm) {
resourceReference(R.string.tangempay_select_plan_confirm_title)
@ -138,8 +169,9 @@ internal class TangemPaySelectPlanModel @Inject constructor(
R.string.tangempay_select_plan_btn_downgrade
},
),
isProcessing = isProcessing,
onCancelClick = ::onBackClick,
onConfirmClick = {},
onConfirmClick = ::onConfirmClick,
)
}
@ -187,7 +219,6 @@ internal class TangemPaySelectPlanModel @Inject constructor(
private val ALLOWED_TYPES = setOf(
TangemPayTariffPlanTransition.Type.UPGRADE,
TangemPayTariffPlanTransition.Type.DOWNGRADE,
TangemPayTariffPlanTransition.Type.ACTIVATION, // TODO v_rodionov: Only for test, must be removed in future
)
}
}

View file

@ -250,6 +250,7 @@ private fun ConfirmFooter(content: TangemPaySelectPlanUM.Content.Confirm, modifi
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
isEnabled = !content.isProcessing,
text = resourceReference(R.string.tangempay_select_plan_btn_cancel),
onClick = content.onCancelClick,
)
@ -257,6 +258,7 @@ private fun ConfirmFooter(content: TangemPaySelectPlanUM.Content.Confirm, modifi
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
isLoading = content.isProcessing,
text = content.confirmButtonText,
onClick = content.onConfirmClick,
)
@ -344,6 +346,7 @@ private fun previewState(isConfirm: Boolean) = TangemPaySelectPlanUM(
),
),
confirmButtonText = stringReference("Upgrade plan"),
isProcessing = false,
onCancelClick = {},
onConfirmClick = {},
)

View file

@ -54,6 +54,7 @@ internal data class TangemPaySelectPlanUM(
val title: TextReference,
val points: ImmutableList<PointUM>,
val confirmButtonText: TextReference,
val isProcessing: Boolean,
val onCancelClick: () -> Unit,
val onConfirmClick: () -> Unit,
) : Content

View file

@ -11,6 +11,7 @@ internal interface TangemPayDetailIntents {
fun onClickWithdraw()
fun onClickTermsAndLimits()
fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan)
fun onCancelPlusTransition(orderId: String)
fun onCardClick(cardId: String)
fun onAddCardClick()
fun onRemoveAccount()

View file

@ -135,6 +135,7 @@ internal class TangemPayDetailsModelTest {
produceTangemPayInitialDataUseCase = mockk(relaxed = true),
onboardingRepository = mockk(relaxed = true),
getCustomerOffers = mockk(relaxed = true),
cancelTangemPayOrderUseCase = mockk(relaxed = true),
)
}