diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 2e6bd18cf2..a4c7fb49c5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -27,6 +27,7 @@ data class CustomerMeResponse( @JsonClass(generateAdapter = true) data class CustomerTariffPlan( @Json(name = "status") val status: String?, + @Json(name = "source") val source: String?, @Json(name = "next_billing_at") val nextBillingAt: String?, @Json(name = "pending_transition_at") val pendingTransitionAt: String?, @Json(name = "tariff_plan") val tariffPlan: TariffPlan?, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index f0093125c2..3acccf5345 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -398,13 +398,52 @@ internal interface TangemPayDataModule { issueCardRepository: TangemPayIssueCardRepository, startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, appCoroutineScope: AppCoroutineScope, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, ): CreateTariffPlanTransitionOrderUseCase { return CreateTariffPlanTransitionOrderUseCase( customerOrderRepository = customerOrderRepository, issueCardRepository = issueCardRepository, startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, appCoroutineScope = appCoroutineScope, ) } + + @Provides + fun provideSubmitTariffTransitionUseCase( + createTransitionOrder: CreateTariffPlanTransitionOrderUseCase, + setPendingTransition: SetTariffPlanPendingTransitionUseCase, + ): SubmitTariffTransitionUseCase { + return SubmitTariffTransitionUseCase( + createTransitionOrder = createTransitionOrder, + setPendingTransition = setPendingTransition, + ) + } + + @Provides + fun provideCancelTariffTransitionUseCase( + cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase, + getTariffTransitionUseCase: GetTangemPayTariffPlanTransitionsUseCase, + submitTariffTransitionUseCase: SubmitTariffTransitionUseCase, + getCurrentTariffUseCase: GetCurrentTariffUseCase, + ): CancelTariffTransitionUseCase { + return CancelTariffTransitionUseCase( + cancelTangemPayOrderUseCase = cancelTangemPayOrderUseCase, + getTariffTransitionUseCase = getTariffTransitionUseCase, + submitTariffTransitionUseCase = submitTariffTransitionUseCase, + getCurrentTariffUseCase = getCurrentTariffUseCase, + ) + } + + @Provides + fun provideGetCurrentTariffUseCase( + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + ): GetCurrentTariffUseCase { + return GetCurrentTariffUseCase( + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + ) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index 81b291df0d..b7dbdb9a34 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -57,6 +57,7 @@ internal object CustomerInfoConverter : Converter null + is PaymentAccountStatusValue.Inactive -> tariffPlan.tariff + is PaymentAccountStatusValue.AwaitingPlanSelection -> tariffPlan + is PaymentAccountStatusValue.Loaded -> tariffPlan?.tariff + } -fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId } +fun PaymentAccountStatusValue.hasAccountData(): Boolean = this is PaymentAccountStatusValue.Loaded || + this is PaymentAccountStatusValue.Deactivated -fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId } +fun PaymentAccountStatusValue.Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId } -fun Loaded.requireCardWithId(cardId: String): TangemPayCard = requireNotNull(findCardWithId(cardId)) \ No newline at end of file +fun PaymentAccountStatusValue.Loaded.findCardWithId(cardId: String): TangemPayCard? { + return cards.firstOrNull { it.id == cardId } +} + +fun PaymentAccountStatusValue.Loaded.requireCardWithId(cardId: String): TangemPayCard { + return requireNotNull(findCardWithId(cardId)) +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayCustomerTariffPlan.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayCustomerTariffPlan.kt index 83915ff9f2..5d0c0479de 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayCustomerTariffPlan.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayCustomerTariffPlan.kt @@ -9,6 +9,7 @@ import java.util.Locale * Customer's current tariff plan. * * @property status Lifecycle status of the subscription. + * @property source Source of tariff plan. Where [Source.DEFAULT] is basic value users with no tariff selection. * @property plan The currently active plan ([TangemPayTariffPlan]). * @property nextBillingAt When the next plan fee is charged; `null` for free plans. * @property pendingPlan Plan the customer will be moved to (scheduled downgrade), or `null`. @@ -17,6 +18,7 @@ import java.util.Locale @Serializable data class TangemPayCustomerTariffPlan( @SerialName("status") val status: Status, + @SerialName("source") val source: Source, @SerialName("plan") val plan: TangemPayTariffPlan, @SerialName("next_billing_at") val nextBillingAt: SerializedDateTime?, @SerialName("pending_plan") val pendingPlan: TangemPayTariffPlan?, @@ -55,4 +57,28 @@ data class TangemPayCustomerTariffPlan( } } } -} \ No newline at end of file + + @Serializable + enum class Source { + @SerialName("DEFAULT") + DEFAULT, + + @SerialName("CUSTOMER") + CUSTOMER, + + @SerialName("UNKNOWN") + UNKNOWN, + ; + + companion object { + fun fromString(value: String?) = when (value?.uppercase(Locale.US)) { + "DEFAULT" -> DEFAULT + "CUSTOMER" -> CUSTOMER + else -> UNKNOWN + } + } + } +} + +val TangemPayCustomerTariffPlan.isDefaultTariff: Boolean + get() = source == TangemPayCustomerTariffPlan.Source.DEFAULT && plan.isBasicTier \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCase.kt new file mode 100644 index 0000000000..544c1cad9e --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.models.account.isDefaultTariff +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.error.VisaApiError + +class CancelTariffTransitionUseCase( + private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase, + private val getTariffTransitionUseCase: GetTangemPayTariffPlanTransitionsUseCase, + private val submitTariffTransitionUseCase: SubmitTariffTransitionUseCase, + private val getCurrentTariffUseCase: GetCurrentTariffUseCase, +) { + suspend operator fun invoke(userWalletId: UserWalletId, orderId: String): Either = either { + cancelTangemPayOrderUseCase.invoke(userWalletId, orderId).bind() + + val (source, tariff) = getCurrentTariffUseCase(userWalletId) ?: return@either + + if (source.isActual() && tariff.isDefaultTariff) { + val transitions = getTariffTransitionUseCase.invoke(userWalletId).bind() + val basicPlanTransition = transitions.find { it.plan.isBasicTier } ?: return@either + submitTariffTransitionUseCase.invoke(userWalletId, basicPlanTransition).bind() + } + } +} \ No newline at end of file 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 6b79a04731..401db0c641 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 @@ -4,6 +4,7 @@ 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.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.OrderType import com.tangem.domain.pay.model.TangemPayOrderInfo @@ -19,6 +20,7 @@ class CreateTariffPlanTransitionOrderUseCase( private val customerOrderRepository: CustomerOrderRepository, private val issueCardRepository: TangemPayIssueCardRepository, private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, private val appCoroutineScope: AppCoroutineScope, ) { suspend operator fun invoke( @@ -57,5 +59,7 @@ class CreateTariffPlanTransitionOrderUseCase( onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) }, ) } + + paymentAccountStatusFetcher.invoke(userWalletId) } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCase.kt new file mode 100644 index 0000000000..abbf835517 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.pay.usecase + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +import com.tangem.domain.models.account.tariffPlan +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.firstOrNull + +class GetCurrentTariffUseCase( + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) { + suspend operator fun invoke(userWalletId: UserWalletId): Pair? { + paymentAccountStatusFetcher.invoke(userWalletId) + val currentStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() + val statusSource = currentStatus?.value?.source + val tariffPlan = currentStatus?.value?.tariffPlan + return if (statusSource != null && tariffPlan != null) { + statusSource to tariffPlan + } else { + null + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCase.kt new file mode 100644 index 0000000000..f43740ecbd --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCase.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.right +import com.tangem.domain.models.account.TangemPayTariffPlanTransition +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.visa.error.VisaApiError + +class SubmitTariffTransitionUseCase( + private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase, + private val setPendingTransition: SetTariffPlanPendingTransitionUseCase, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + transition: TangemPayTariffPlanTransition, + ): Either { + return when (transition.type) { + TangemPayTariffPlanTransition.Type.ACTIVATION, + TangemPayTariffPlanTransition.Type.UPGRADE, + -> { + createTransitionOrder( + userWalletId = userWalletId, + targetTariffPlanId = transition.plan.id, + transitionType = transition.type, + ) + } + TangemPayTariffPlanTransition.Type.DOWNGRADE -> { + setPendingTransition( + userWalletId = userWalletId, + pendingTariffPlanId = transition.plan.id, + ) + } + TangemPayTariffPlanTransition.Type.SYSTEM_DOWNGRADE, + TangemPayTariffPlanTransition.Type.UNKNOWN, + -> Unit.right() + } + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCaseTest.kt new file mode 100644 index 0000000000..50c09eb007 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CancelTariffTransitionUseCaseTest.kt @@ -0,0 +1,199 @@ +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.StatusSource +import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +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.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class CancelTariffTransitionUseCaseTest { + + private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase = mockk() + private val getTariffTransitionUseCase: GetTangemPayTariffPlanTransitionsUseCase = mockk() + private val submitTariffTransitionUseCase: SubmitTariffTransitionUseCase = mockk() + private val getCurrentTariffUseCase: GetCurrentTariffUseCase = mockk() + + private val useCase = CancelTariffTransitionUseCase( + cancelTangemPayOrderUseCase = cancelTangemPayOrderUseCase, + getTariffTransitionUseCase = getTariffTransitionUseCase, + submitTariffTransitionUseCase = submitTariffTransitionUseCase, + getCurrentTariffUseCase = getCurrentTariffUseCase, + ) + + @Test + fun `GIVEN cancel order fails WHEN invoke THEN returns Left and skips further steps`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(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) { getCurrentTariffUseCase(any()) } + coVerify(exactly = 0) { getTariffTransitionUseCase(any()) } + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN current tariff is null WHEN invoke THEN returns Right and skips transition`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns null + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { getTariffTransitionUseCase(any()) } + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN source is not actual WHEN invoke THEN returns Right and skips transition`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.CACHE to DEFAULT_BASIC_TARIFF) + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { getTariffTransitionUseCase(any()) } + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN tariff is not default WHEN invoke THEN returns Right and skips transition`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.ACTUAL to CUSTOMER_PLUS_TARIFF) + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { getTariffTransitionUseCase(any()) } + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN actual default tariff AND getTransitions fails WHEN invoke THEN returns Left`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.ACTUAL to DEFAULT_BASIC_TARIFF) + coEvery { getTariffTransitionUseCase(USER_WALLET_ID) } returns VisaApiError.Unspecified.left() + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN actual default tariff AND basic transition exists WHEN invoke THEN submits basic transition`() = + runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.ACTUAL to DEFAULT_BASIC_TARIFF) + coEvery { getTariffTransitionUseCase(USER_WALLET_ID) } returns + listOf(PLUS_TRANSITION, BASIC_TRANSITION).right() + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, BASIC_TRANSITION) } returns Unit.right() + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { submitTariffTransitionUseCase(USER_WALLET_ID, BASIC_TRANSITION) } + } + + @Test + fun `GIVEN actual default tariff AND no basic transition WHEN invoke THEN returns Right without submit`() = + runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.ACTUAL to DEFAULT_BASIC_TARIFF) + coEvery { getTariffTransitionUseCase(USER_WALLET_ID) } returns listOf(PLUS_TRANSITION).right() + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN actual default tariff AND submit fails WHEN invoke THEN returns Left`() = runTest { + // GIVEN + coEvery { cancelTangemPayOrderUseCase(USER_WALLET_ID, ORDER_ID) } returns Unit.right() + coEvery { getCurrentTariffUseCase(USER_WALLET_ID) } returns (StatusSource.ACTUAL to DEFAULT_BASIC_TARIFF) + coEvery { getTariffTransitionUseCase(USER_WALLET_ID) } returns listOf(BASIC_TRANSITION).right() + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, BASIC_TRANSITION) } returns + VisaApiError.Unspecified.left() + + // WHEN + val result = useCase(USER_WALLET_ID, ORDER_ID) + + // THEN + assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) + } + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val ORDER_ID = "order-test-1" + + val BASIC_PLAN = TangemPayTariffPlan( + id = "plan-basic", + tierId = "BASIC", + isBasicTier = true, + name = "Basic", + programName = "program-basic", + descriptionItems = emptyList(), + ) + val PLUS_PLAN = TangemPayTariffPlan( + id = "plan-plus", + tierId = "PLUS", + isBasicTier = false, + name = "Plus", + programName = "program-plus", + descriptionItems = emptyList(), + ) + val DEFAULT_BASIC_TARIFF = customerTariff(TangemPayCustomerTariffPlan.Source.DEFAULT, BASIC_PLAN) + val CUSTOMER_PLUS_TARIFF = customerTariff(TangemPayCustomerTariffPlan.Source.CUSTOMER, PLUS_PLAN) + + val BASIC_TRANSITION = TangemPayTariffPlanTransition( + type = TangemPayTariffPlanTransition.Type.SYSTEM_DOWNGRADE, + plan = BASIC_PLAN, + ) + val PLUS_TRANSITION = TangemPayTariffPlanTransition( + type = TangemPayTariffPlanTransition.Type.UPGRADE, + plan = PLUS_PLAN, + ) + + private fun customerTariff(source: TangemPayCustomerTariffPlan.Source, plan: TangemPayTariffPlan) = + TangemPayCustomerTariffPlan( + status = TangemPayCustomerTariffPlan.Status.ACTIVE, + source = source, + plan = plan, + nextBillingAt = null, + pendingPlan = null, + pendingTransitionAt = null, + ) + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCaseTest.kt index 6e3fb6824e..673d4cbaf8 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CreateTariffPlanTransitionOrderUseCaseTest.kt @@ -8,6 +8,7 @@ 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.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.OrderType import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.TangemPayIssueCardRepository @@ -24,11 +25,13 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest { private val customerOrderRepository: CustomerOrderRepository = mockk() private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true) private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true) + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxed = true) private val useCase = CreateTariffPlanTransitionOrderUseCase( customerOrderRepository = customerOrderRepository, issueCardRepository = issueCardRepository, startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, appCoroutineScope = TestAppCoroutineScope(), ) @@ -48,6 +51,7 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest { customerOrderRepository.createOrder(any(), any(), any(), any(), any(), any()) } coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) } + coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any()) } } @Test @@ -118,6 +122,7 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest { // THEN assertThat(result.isRight()).isTrue() coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(USER_WALLET_ID, newOrder.id) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } } private fun order(id: String, status: OrderStatus): Order = Order( diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCaseTest.kt new file mode 100644 index 0000000000..22723b4cfb --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetCurrentTariffUseCaseTest.kt @@ -0,0 +1,101 @@ +package com.tangem.domain.pay.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.AccountStatus +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.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class GetCurrentTariffUseCaseTest { + + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxed = true) + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + + private val useCase = GetCurrentTariffUseCase( + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + ) + + @Test + fun `GIVEN status with tariff WHEN invoke THEN fetches and returns source with tariff`() = runTest { + // GIVEN + val status = paymentStatus(value = awaitingPlanSelection(StatusSource.ACTUAL, CUSTOMER_TARIFF)) + every { paymentAccountStatusSupplier.invoke(USER_WALLET_ID) } returns flowOf(status) + + // WHEN + val result = useCase(USER_WALLET_ID) + + // THEN + assertThat(result).isEqualTo(StatusSource.ACTUAL to CUSTOMER_TARIFF) + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN empty supplier flow WHEN invoke THEN returns null`() = runTest { + // GIVEN + every { paymentAccountStatusSupplier.invoke(USER_WALLET_ID) } returns emptyFlow() + + // WHEN + val result = useCase(USER_WALLET_ID) + + // THEN + assertThat(result).isNull() + } + + @Test + fun `GIVEN status without tariff WHEN invoke THEN returns null`() = runTest { + // GIVEN + val status = paymentStatus(value = PaymentAccountStatusValue.NotCreated) + every { paymentAccountStatusSupplier.invoke(USER_WALLET_ID) } returns flowOf(status) + + // WHEN + val result = useCase(USER_WALLET_ID) + + // THEN + assertThat(result).isNull() + } + + private fun paymentStatus(value: PaymentAccountStatusValue) = AccountStatus.Payment( + account = mockk(), + value = value, + ) + + private fun awaitingPlanSelection(source: StatusSource, tariff: TangemPayCustomerTariffPlan) = + PaymentAccountStatusValue.AwaitingPlanSelection( + source = source, + cryptoCurrency = mockk(), + tariffPlan = tariff, + ) + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + + val PLAN = TangemPayTariffPlan( + id = "plan-plus", + tierId = "PLUS", + isBasicTier = false, + name = "Plus", + programName = "program-plus", + descriptionItems = emptyList(), + ) + val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan( + status = TangemPayCustomerTariffPlan.Status.ACTIVE, + source = TangemPayCustomerTariffPlan.Source.CUSTOMER, + plan = PLAN, + nextBillingAt = null, + pendingPlan = null, + pendingTransitionAt = null, + ) + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanStateUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanStateUseCaseTest.kt index ef00d92ea7..74d8e40cee 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanStateUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/GetTangemPayTariffPlanStateUseCaseTest.kt @@ -169,6 +169,7 @@ internal class GetTangemPayTariffPlanStateUseCaseTest { ) val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan( status = TangemPayCustomerTariffPlan.Status.ACTIVE, + source = TangemPayCustomerTariffPlan.Source.CUSTOMER, plan = CURRENT_PLAN, nextBillingAt = null, pendingPlan = null, diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCaseTest.kt new file mode 100644 index 0000000000..adb15bb2ce --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/SubmitTariffTransitionUseCaseTest.kt @@ -0,0 +1,153 @@ +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.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.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class SubmitTariffTransitionUseCaseTest { + + private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase = mockk() + private val setPendingTransition: SetTariffPlanPendingTransitionUseCase = mockk() + + private val useCase = SubmitTariffTransitionUseCase( + createTransitionOrder = createTransitionOrder, + setPendingTransition = setPendingTransition, + ) + + @Test + fun `GIVEN activation transition WHEN invoke THEN creates transition order`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.ACTIVATION) + coEvery { + createTransitionOrder(USER_WALLET_ID, PLAN_ID, TangemPayTariffPlanTransition.Type.ACTIVATION) + } returns Unit.right() + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + createTransitionOrder(USER_WALLET_ID, PLAN_ID, TangemPayTariffPlanTransition.Type.ACTIVATION) + } + coVerify(exactly = 0) { setPendingTransition(any(), any()) } + } + + @Test + fun `GIVEN upgrade transition WHEN invoke THEN creates transition order`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.UPGRADE) + coEvery { + createTransitionOrder(USER_WALLET_ID, PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE) + } returns Unit.right() + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + createTransitionOrder(USER_WALLET_ID, PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE) + } + coVerify(exactly = 0) { setPendingTransition(any(), any()) } + } + + @Test + fun `GIVEN createTransitionOrder fails WHEN invoke THEN returns Left`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.UPGRADE) + coEvery { + createTransitionOrder(USER_WALLET_ID, PLAN_ID, TangemPayTariffPlanTransition.Type.UPGRADE) + } returns VisaApiError.Unspecified.left() + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) + } + + @Test + fun `GIVEN downgrade transition WHEN invoke THEN sets pending transition`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.DOWNGRADE) + coEvery { setPendingTransition(USER_WALLET_ID, PLAN_ID) } returns Unit.right() + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { setPendingTransition(USER_WALLET_ID, PLAN_ID) } + coVerify(exactly = 0) { createTransitionOrder(any(), any(), any()) } + } + + @Test + fun `GIVEN setPendingTransition fails WHEN invoke THEN returns Left`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.DOWNGRADE) + coEvery { setPendingTransition(USER_WALLET_ID, PLAN_ID) } returns VisaApiError.Unspecified.left() + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified) + } + + @Test + fun `GIVEN system downgrade transition WHEN invoke THEN returns Right without side effects`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.SYSTEM_DOWNGRADE) + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { createTransitionOrder(any(), any(), any()) } + coVerify(exactly = 0) { setPendingTransition(any(), any()) } + } + + @Test + fun `GIVEN unknown transition WHEN invoke THEN returns Right without side effects`() = runTest { + // GIVEN + val transition = transition(TangemPayTariffPlanTransition.Type.UNKNOWN) + + // WHEN + val result = useCase(USER_WALLET_ID, transition) + + // THEN + assertThat(result.isRight()).isTrue() + coVerify(exactly = 0) { createTransitionOrder(any(), any(), any()) } + coVerify(exactly = 0) { setPendingTransition(any(), any()) } + } + + private fun transition(type: TangemPayTariffPlanTransition.Type) = TangemPayTariffPlanTransition( + type = type, + plan = PLAN, + ) + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val PLAN_ID = "plan-plus" + + val PLAN = TangemPayTariffPlan( + id = PLAN_ID, + tierId = "PLUS", + isBasicTier = false, + name = "Plus", + programName = "program-plus", + descriptionItems = emptyList(), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNotificationFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNotificationFactory.kt index eaa566f1a0..20e3e483c7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNotificationFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNotificationFactory.kt @@ -60,7 +60,7 @@ internal class TangemPayDetailsNotificationFactory( R.string.tangempay_card_details_awaiting_deposit_cancel_button, wrappedList(orderStep.toPlan.name, orderStep.fromPlan.name), ), - onClick = { intents.onCancelPlusTransition(order.orderId) }, + onClick = { intents.onCancelTariffTransition(order.orderId) }, ), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index b1b8d9efef..c643dcdfea 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -80,7 +80,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase, private val onboardingRepository: OnboardingRepository, private val getCustomerOffers: GetCustomerOffersUseCase, - private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase, + private val cancelTariffTransitionUseCase: CancelTariffTransitionUseCase, private val getCashbackSummaryUseCase: GetCashbackSummaryUseCase, private val getCashbackDeactivationDismissedUseCase: GetCashbackDeactivationDismissedUseCase, private val setCashbackDeactivationDismissedUseCase: SetCashbackDeactivationDismissedUseCase, @@ -156,11 +156,11 @@ internal class TangemPayDetailsModel @Inject constructor( .launchIn(modelScope) } - override fun onCancelPlusTransition(orderId: String) { + override fun onCancelTariffTransition(orderId: String) { analytics.send(TangemPayAnalyticsEvents.Tiers.CancelPlusMoveToBasicClicked()) uiState.update(TangemPayErrorNotificationTransformer(shouldShowProgress = true)) modelScope.launch { - cancelTangemPayOrderUseCase(userWalletId = userWalletId, orderId = orderId) + cancelTariffTransitionUseCase(userWalletId = userWalletId, orderId = orderId) .onLeft { uiMessageSender.send(TangemPayMessagesFactory.createGenericError()) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt index bd77d1677f..a84cf20dc5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModel.kt @@ -1,7 +1,6 @@ package com.tangem.features.tangempay.tiers.select import androidx.compose.runtime.Stable -import arrow.core.Either import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -14,11 +13,9 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.DateTimeFormatters 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.domain.pay.usecase.SetTariffPlanPendingTransitionUseCase +import com.tangem.domain.pay.usecase.SubmitTariffTransitionUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.error.VisaApiError import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.tiers.formatNextBillingDateOrNull @@ -41,8 +38,7 @@ internal class TangemPaySelectPlanModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase, - private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase, - private val setPendingTransition: SetTariffPlanPendingTransitionUseCase, + private val submitTariffTransitionUseCase: SubmitTariffTransitionUseCase, private val uiMessageSender: UiMessageSender, private val analytics: AnalyticsEventHandler, ) : Model() { @@ -140,31 +136,11 @@ internal class TangemPaySelectPlanModel @Inject constructor( if (transition.type == TangemPayTariffPlanTransition.Type.UPGRADE) { analytics.send(TangemPayAnalyticsEvents.Tiers.PlanChangeUpgradeClicked()) } - when (transition.type) { - TangemPayTariffPlanTransition.Type.ACTIVATION, - TangemPayTariffPlanTransition.Type.UPGRADE, - -> submitTransition { - createTransitionOrder( - userWalletId = params.userWalletId, - targetTariffPlanId = transition.plan.id, - transitionType = transition.type, - ) - } - TangemPayTariffPlanTransition.Type.DOWNGRADE -> submitTransition { - setPendingTransition( - userWalletId = params.userWalletId, - pendingTariffPlanId = transition.plan.id, - ) - } - else -> Unit - } - } - private fun submitTransition(action: suspend () -> Either) { isProcessing = true state.update { buildState() } modelScope.launch { - action().fold( + submitTariffTransitionUseCase(params.userWalletId, transition).fold( ifRight = { when (params.source) { TangemPaySelectPlanSource.TIERS_ONBOARDING -> { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt index 00b1b9e9c7..bcd13e7275 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -14,7 +14,7 @@ internal interface TangemPayDetailIntents { fun onClickVisaBenefits() fun onClickCashback() fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) - fun onCancelPlusTransition(orderId: String) + fun onCancelTariffTransition(orderId: String) fun onCardClick(cardId: String) fun onAddCardClick(tariffState: TangemPayTariffPlanState?) fun onRemoveAccount() diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt index db74ac5718..51ec8a4352 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -118,7 +118,7 @@ internal class TangemPayDetailsModelTest { produceTangemPayInitialDataUseCase = mockk(relaxed = true), onboardingRepository = mockk(relaxed = true), getCustomerOffers = mockk(relaxed = true), - cancelTangemPayOrderUseCase = mockk(relaxed = true), + cancelTariffTransitionUseCase = mockk(relaxed = true), getCashbackSummaryUseCase = mockk(relaxed = true), getCashbackDeactivationDismissedUseCase = mockk(relaxed = true), setCashbackDeactivationDismissedUseCase = mockk(relaxed = true), diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModelTest.kt new file mode 100644 index 0000000000..7e8f1a6407 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/tiers/select/TangemPaySelectPlanModelTest.kt @@ -0,0 +1,351 @@ +package com.tangem.features.tangempay.tiers.select + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +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.pay.usecase.GetTangemPayTariffPlanTransitionsUseCase +import com.tangem.domain.pay.usecase.SubmitTariffTransitionUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import org.junit.jupiter.api.Test + +internal class TangemPaySelectPlanModelTest { + + private val router: Router = mockk(relaxed = true) + private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase = mockk() + private val submitTariffTransitionUseCase: SubmitTariffTransitionUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) + + private fun createModel( + source: TangemPaySelectPlanSource = TangemPaySelectPlanSource.CHANGE_PLAN, + transitions: List = DEFAULT_TRANSITIONS, + ): TangemPaySelectPlanModel { + coEvery { getTransitions(USER_WALLET_ID) } returns transitions.right() + return TangemPaySelectPlanModel( + paramsContainer = MutableParamsContainer( + TangemPaySelectPlanComponent.Params( + userWalletId = USER_WALLET_ID, + tariffPlan = CUSTOMER_TARIFF, + source = source, + ), + ), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + getTransitions = getTransitions, + submitTariffTransitionUseCase = submitTariffTransitionUseCase, + uiMessageSender = uiMessageSender, + analytics = analytics, + ) + } + + @Test + fun `GIVEN mixed transitions WHEN model created THEN only allowed types are shown as plans`() { + // GIVEN + WHEN + val model = createModel() + + // THEN + assertThat(model.state.value.plans).hasSize(3) + verify(exactly = 1) { + analytics.send(ofType()) + } + } + + @Test + fun `GIVEN getTransitions fails WHEN model created THEN plans are empty`() { + // GIVEN + coEvery { getTransitions(USER_WALLET_ID) } returns VisaApiError.Unspecified.left() + + // WHEN + val model = TangemPaySelectPlanModel( + paramsContainer = MutableParamsContainer( + TangemPaySelectPlanComponent.Params( + userWalletId = USER_WALLET_ID, + tariffPlan = CUSTOMER_TARIFF, + source = TangemPaySelectPlanSource.CHANGE_PLAN, + ), + ), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + getTransitions = getTransitions, + submitTariffTransitionUseCase = submitTariffTransitionUseCase, + uiMessageSender = uiMessageSender, + analytics = analytics, + ) + + // THEN + assertThat(model.state.value.plans).isEmpty() + } + + @Test + fun `GIVEN new index WHEN onPlanSelected THEN selectedIndex updates and swipe analytics sent`() { + // GIVEN + val model = createModel() + + // WHEN + model.state.value.onPlanSelected(1) + + // THEN + assertThat(model.state.value.selectedIndex).isEqualTo(1) + verify(exactly = 1) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN same index WHEN onPlanSelected THEN nothing changes and no swipe analytics`() { + // GIVEN + val model = createModel() + + // WHEN + model.state.value.onPlanSelected(0) + + // THEN + assertThat(model.state.value.selectedIndex).isEqualTo(0) + verify(exactly = 0) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN change plan source WHEN onSelectClick THEN switches to confirm content`() { + // GIVEN + val model = createModel(source = TangemPaySelectPlanSource.CHANGE_PLAN) + + // WHEN + selectContent(model).onSelectClick() + + // THEN + assertThat(model.state.value.content).isInstanceOf(TangemPaySelectPlanUM.Content.Confirm::class.java) + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 1) { + analytics.send(ofType()) + } + } + + @Test + fun `GIVEN onboarding source AND submit succeeds WHEN onSelectClick THEN replaces to account details`() { + // GIVEN + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, any()) } returns Unit.right() + val model = createModel(source = TangemPaySelectPlanSource.TIERS_ONBOARDING) + + // WHEN + selectContent(model).onSelectClick() + + // THEN + coVerify(exactly = 1) { submitTariffTransitionUseCase(USER_WALLET_ID, UPGRADE_TRANSITION) } + verify(exactly = 1) { router.replaceAll(TangemPayAccountDetailsInnerRoute.AccountDetails) } + } + + @Test + fun `GIVEN no allowed transitions WHEN onSelectClick THEN nothing happens`() { + // GIVEN + val model = createModel(transitions = listOf(SYSTEM_DOWNGRADE_TRANSITION)) + + // WHEN + selectContent(model).onSelectClick() + + // THEN + verify(exactly = 0) { analytics.send(ofType()) } + coVerify(exactly = 0) { submitTariffTransitionUseCase(any(), any()) } + } + + @Test + fun `GIVEN allowed transitions WHEN onComparePlansClick THEN compare is shown with analytics`() { + // GIVEN + val model = createModel() + + // WHEN + selectContent(model).onComparePlansClick() + + // THEN + assertThat(model.state.value.compare).isNotNull() + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 1) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN no allowed transitions WHEN onComparePlansClick THEN compare stays hidden`() { + // GIVEN + val model = createModel(transitions = listOf(SYSTEM_DOWNGRADE_TRANSITION)) + + // WHEN + selectContent(model).onComparePlansClick() + + // THEN + assertThat(model.state.value.compare).isNull() + verify(exactly = 0) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN compare shown WHEN onDismiss THEN compare hidden with analytics`() { + // GIVEN + val model = createModel() + selectContent(model).onComparePlansClick() + + // WHEN + model.state.value.compare!!.onDismiss() + + // THEN + assertThat(model.state.value.compare).isNull() + verify(exactly = 1) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN confirm shown WHEN onBackClick THEN returns to select content without pop`() { + // GIVEN + val model = createModel(source = TangemPaySelectPlanSource.CHANGE_PLAN) + selectContent(model).onSelectClick() + + // WHEN + model.onBackClick() + + // THEN + assertThat(model.state.value.content).isInstanceOf(TangemPaySelectPlanUM.Content.Select::class.java) + verify(exactly = 1) { analytics.send(ofType()) } + verify(exactly = 0) { router.pop() } + } + + @Test + fun `GIVEN select shown WHEN onBackClick THEN router pops`() { + // GIVEN + val model = createModel() + + // WHEN + model.onBackClick() + + // THEN + verify(exactly = 1) { router.pop() } + } + + @Test + fun `GIVEN not processing WHEN onCloseClick THEN router pops`() { + // GIVEN + val model = createModel() + + // WHEN + model.state.value.onCloseClick() + + // THEN + verify(exactly = 1) { router.pop() } + } + + @Test + fun `GIVEN confirm AND submit fails WHEN onConfirmClick THEN processing reset and error shown`() { + // GIVEN + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, any()) } returns VisaApiError.Unspecified.left() + val model = createModel(source = TangemPaySelectPlanSource.CHANGE_PLAN) + selectContent(model).onSelectClick() + + // WHEN + confirmContent(model).onConfirmClick() + + // THEN + verify(exactly = 1) { uiMessageSender.send(any()) } + assertThat(confirmContent(model).isProcessing).isFalse() + verify(exactly = 0) { router.pop() } + } + + @Test + fun `GIVEN confirm AND submit succeeds WHEN onConfirmClick THEN router pops`() { + // GIVEN + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, any()) } returns Unit.right() + val model = createModel(source = TangemPaySelectPlanSource.CHANGE_PLAN) + selectContent(model).onSelectClick() + + // WHEN + confirmContent(model).onConfirmClick() + + // THEN + verify(exactly = 1) { router.pop() } + } + + @Test + fun `GIVEN upgrade transition WHEN applied THEN upgrade analytics sent`() { + // GIVEN + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, any()) } returns Unit.right() + val model = createModel(source = TangemPaySelectPlanSource.CHANGE_PLAN) + selectContent(model).onSelectClick() + + // WHEN + confirmContent(model).onConfirmClick() + + // THEN + verify(exactly = 1) { analytics.send(ofType()) } + } + + @Test + fun `GIVEN processing in progress WHEN onCloseClick THEN pop is ignored`() { + // GIVEN + val pending = CompletableDeferred>() + coEvery { submitTariffTransitionUseCase(USER_WALLET_ID, any()) } coAnswers { pending.await() } + val model = createModel(source = TangemPaySelectPlanSource.TIERS_ONBOARDING) + + // WHEN + selectContent(model).onSelectClick() + model.state.value.onCloseClick() + + // THEN + verify(exactly = 0) { router.pop() } + } + + private fun selectContent(model: TangemPaySelectPlanModel) = + model.state.value.content as TangemPaySelectPlanUM.Content.Select + + private fun confirmContent(model: TangemPaySelectPlanModel) = + model.state.value.content as TangemPaySelectPlanUM.Content.Confirm + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + + val UPGRADE_TRANSITION = transition(TangemPayTariffPlanTransition.Type.UPGRADE, "PLUS", isBasic = false) + val DOWNGRADE_TRANSITION = transition(TangemPayTariffPlanTransition.Type.DOWNGRADE, "BASIC", isBasic = true) + val ACTIVATION_TRANSITION = transition(TangemPayTariffPlanTransition.Type.ACTIVATION, "PLUS", isBasic = false) + val SYSTEM_DOWNGRADE_TRANSITION = + transition(TangemPayTariffPlanTransition.Type.SYSTEM_DOWNGRADE, "BASIC", isBasic = true) + val UNKNOWN_TRANSITION = transition(TangemPayTariffPlanTransition.Type.UNKNOWN, "BASIC", isBasic = true) + + val DEFAULT_TRANSITIONS = listOf( + UPGRADE_TRANSITION, + DOWNGRADE_TRANSITION, + ACTIVATION_TRANSITION, + SYSTEM_DOWNGRADE_TRANSITION, + UNKNOWN_TRANSITION, + ) + + val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan( + status = TangemPayCustomerTariffPlan.Status.ACTIVE, + source = TangemPayCustomerTariffPlan.Source.CUSTOMER, + plan = plan("PLUS", isBasic = false), + nextBillingAt = null, + pendingPlan = null, + pendingTransitionAt = null, + ) + + private fun transition( + type: TangemPayTariffPlanTransition.Type, + tierId: String, + isBasic: Boolean, + ) = TangemPayTariffPlanTransition(type = type, plan = plan(tierId, isBasic)) + + private fun plan(tierId: String, isBasic: Boolean) = TangemPayTariffPlan( + id = "plan-${tierId.lowercase()}", + tierId = tierId, + isBasicTier = isBasic, + name = tierId, + programName = "program-$tierId", + descriptionItems = emptyList(), + ) + } +} \ No newline at end of file