Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-23 06:30:53 -07:00
parent aef7c97866
commit eda701fed9
21 changed files with 1006 additions and 40 deletions

View file

@ -27,6 +27,7 @@ data class CustomerMeResponse(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class CustomerTariffPlan( data class CustomerTariffPlan(
@Json(name = "status") val status: String?, @Json(name = "status") val status: String?,
@Json(name = "source") val source: String?,
@Json(name = "next_billing_at") val nextBillingAt: String?, @Json(name = "next_billing_at") val nextBillingAt: String?,
@Json(name = "pending_transition_at") val pendingTransitionAt: String?, @Json(name = "pending_transition_at") val pendingTransitionAt: String?,
@Json(name = "tariff_plan") val tariffPlan: TariffPlan?, @Json(name = "tariff_plan") val tariffPlan: TariffPlan?,

View file

@ -398,13 +398,52 @@ internal interface TangemPayDataModule {
issueCardRepository: TangemPayIssueCardRepository, issueCardRepository: TangemPayIssueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope, appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CreateTariffPlanTransitionOrderUseCase { ): CreateTariffPlanTransitionOrderUseCase {
return CreateTariffPlanTransitionOrderUseCase( return CreateTariffPlanTransitionOrderUseCase(
customerOrderRepository = customerOrderRepository, customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository, issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = appCoroutineScope, 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,
)
}
} }
} }

View file

@ -57,6 +57,7 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
val plan = tariffPlan?.toDomain() ?: return null val plan = tariffPlan?.toDomain() ?: return null
return TangemPayCustomerTariffPlan( return TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.fromString(status), status = TangemPayCustomerTariffPlan.Status.fromString(status),
source = TangemPayCustomerTariffPlan.Source.fromString(source),
plan = plan, plan = plan,
nextBillingAt = nextBillingAt.toDateTimeOrNull(), nextBillingAt = nextBillingAt.toDateTimeOrNull(),
pendingPlan = pendingTariffPlan?.toDomain(), pendingPlan = pendingTariffPlan?.toDomain(),

View file

@ -131,6 +131,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
private val customerTariffPlan = TangemPayCustomerTariffPlan( private val customerTariffPlan = TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.ACTIVE, status = TangemPayCustomerTariffPlan.Status.ACTIVE,
source = TangemPayCustomerTariffPlan.Source.CUSTOMER,
plan = basicPlan, plan = basicPlan,
nextBillingAt = null, nextBillingAt = null,
pendingPlan = null, pendingPlan = null,

View file

@ -2,8 +2,6 @@ package com.tangem.domain.models.account
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.PaymentAccountStatusValue.Loaded
import com.tangem.domain.models.account.PaymentAccountStatusValue.Deactivated
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.kyc.KycStatus
@ -326,10 +324,30 @@ private fun buildCryptoCurrencyStatusValue(
} }
} }
fun PaymentAccountStatusValue.hasAccountData(): Boolean = this is Loaded || this is Deactivated val PaymentAccountStatusValue.tariffPlan: TangemPayCustomerTariffPlan?
get() = when (this) {
is PaymentAccountStatusValue.Error,
is PaymentAccountStatusValue.IssuingCard,
is PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.Deactivated,
-> 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)) fun PaymentAccountStatusValue.Loaded.findCardWithId(cardId: String): TangemPayCard? {
return cards.firstOrNull { it.id == cardId }
}
fun PaymentAccountStatusValue.Loaded.requireCardWithId(cardId: String): TangemPayCard {
return requireNotNull(findCardWithId(cardId))
}

View file

@ -9,6 +9,7 @@ import java.util.Locale
* Customer's current tariff plan. * Customer's current tariff plan.
* *
* @property status Lifecycle status of the subscription. * @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 plan The currently active plan ([TangemPayTariffPlan]).
* @property nextBillingAt When the next plan fee is charged; `null` for free plans. * @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`. * @property pendingPlan Plan the customer will be moved to (scheduled downgrade), or `null`.
@ -17,6 +18,7 @@ import java.util.Locale
@Serializable @Serializable
data class TangemPayCustomerTariffPlan( data class TangemPayCustomerTariffPlan(
@SerialName("status") val status: Status, @SerialName("status") val status: Status,
@SerialName("source") val source: Source,
@SerialName("plan") val plan: TangemPayTariffPlan, @SerialName("plan") val plan: TangemPayTariffPlan,
@SerialName("next_billing_at") val nextBillingAt: SerializedDateTime?, @SerialName("next_billing_at") val nextBillingAt: SerializedDateTime?,
@SerialName("pending_plan") val pendingPlan: TangemPayTariffPlan?, @SerialName("pending_plan") val pendingPlan: TangemPayTariffPlan?,
@ -55,4 +57,28 @@ data class TangemPayCustomerTariffPlan(
} }
} }
} }
}
@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

View file

@ -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<VisaApiError, Unit> = 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()
}
}
}

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.domain.models.account.TangemPayTariffPlanTransition import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId 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.OrderStatus
import com.tangem.domain.pay.model.OrderType import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayOrderInfo
@ -19,6 +20,7 @@ class CreateTariffPlanTransitionOrderUseCase(
private val customerOrderRepository: CustomerOrderRepository, private val customerOrderRepository: CustomerOrderRepository,
private val issueCardRepository: TangemPayIssueCardRepository, private val issueCardRepository: TangemPayIssueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope, private val appCoroutineScope: AppCoroutineScope,
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
@ -57,5 +59,7 @@ class CreateTariffPlanTransitionOrderUseCase(
onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) }, onTerminalReached = { issueCardRepository.removeIssueOrderId(userWalletId, order.id) },
) )
} }
paymentAccountStatusFetcher.invoke(userWalletId)
} }
} }

View file

@ -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<StatusSource, TangemPayCustomerTariffPlan>? {
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
}
}
}

View file

@ -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<VisaApiError, Unit> {
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()
}
}
}

View file

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

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderStep 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.model.OrderType
import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
@ -24,11 +25,13 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest {
private val customerOrderRepository: CustomerOrderRepository = mockk() private val customerOrderRepository: CustomerOrderRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true) private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true) private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk(relaxed = true)
private val useCase = CreateTariffPlanTransitionOrderUseCase( private val useCase = CreateTariffPlanTransitionOrderUseCase(
customerOrderRepository = customerOrderRepository, customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository, issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = TestAppCoroutineScope(), appCoroutineScope = TestAppCoroutineScope(),
) )
@ -48,6 +51,7 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest {
customerOrderRepository.createOrder(any(), any(), any(), any(), any(), any()) customerOrderRepository.createOrder(any(), any(), any(), any(), any(), any())
} }
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) } coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
} }
@Test @Test
@ -118,6 +122,7 @@ internal class CreateTariffPlanTransitionOrderUseCaseTest {
// THEN // THEN
assertThat(result.isRight()).isTrue() assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(USER_WALLET_ID, newOrder.id) } 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( private fun order(id: String, status: OrderStatus): Order = Order(

View file

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

View file

@ -169,6 +169,7 @@ internal class GetTangemPayTariffPlanStateUseCaseTest {
) )
val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan( val CUSTOMER_TARIFF = TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.ACTIVE, status = TangemPayCustomerTariffPlan.Status.ACTIVE,
source = TangemPayCustomerTariffPlan.Source.CUSTOMER,
plan = CURRENT_PLAN, plan = CURRENT_PLAN,
nextBillingAt = null, nextBillingAt = null,
pendingPlan = null, pendingPlan = null,

View file

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

View file

@ -60,7 +60,7 @@ internal class TangemPayDetailsNotificationFactory(
R.string.tangempay_card_details_awaiting_deposit_cancel_button, R.string.tangempay_card_details_awaiting_deposit_cancel_button,
wrappedList(orderStep.toPlan.name, orderStep.fromPlan.name), wrappedList(orderStep.toPlan.name, orderStep.fromPlan.name),
), ),
onClick = { intents.onCancelPlusTransition(order.orderId) }, onClick = { intents.onCancelTariffTransition(order.orderId) },
), ),
) )
} }

View file

@ -80,7 +80,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase, private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
private val onboardingRepository: OnboardingRepository, private val onboardingRepository: OnboardingRepository,
private val getCustomerOffers: GetCustomerOffersUseCase, private val getCustomerOffers: GetCustomerOffersUseCase,
private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase, private val cancelTariffTransitionUseCase: CancelTariffTransitionUseCase,
private val getCashbackSummaryUseCase: GetCashbackSummaryUseCase, private val getCashbackSummaryUseCase: GetCashbackSummaryUseCase,
private val getCashbackDeactivationDismissedUseCase: GetCashbackDeactivationDismissedUseCase, private val getCashbackDeactivationDismissedUseCase: GetCashbackDeactivationDismissedUseCase,
private val setCashbackDeactivationDismissedUseCase: SetCashbackDeactivationDismissedUseCase, private val setCashbackDeactivationDismissedUseCase: SetCashbackDeactivationDismissedUseCase,
@ -156,11 +156,11 @@ internal class TangemPayDetailsModel @Inject constructor(
.launchIn(modelScope) .launchIn(modelScope)
} }
override fun onCancelPlusTransition(orderId: String) { override fun onCancelTariffTransition(orderId: String) {
analytics.send(TangemPayAnalyticsEvents.Tiers.CancelPlusMoveToBasicClicked()) analytics.send(TangemPayAnalyticsEvents.Tiers.CancelPlusMoveToBasicClicked())
uiState.update(TangemPayErrorNotificationTransformer(shouldShowProgress = true)) uiState.update(TangemPayErrorNotificationTransformer(shouldShowProgress = true))
modelScope.launch { modelScope.launch {
cancelTangemPayOrderUseCase(userWalletId = userWalletId, orderId = orderId) cancelTariffTransitionUseCase(userWalletId = userWalletId, orderId = orderId)
.onLeft { .onLeft {
uiMessageSender.send(TangemPayMessagesFactory.createGenericError()) uiMessageSender.send(TangemPayMessagesFactory.createGenericError())
} }

View file

@ -1,7 +1,6 @@
package com.tangem.features.tangempay.tiers.select package com.tangem.features.tangempay.tiers.select
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import arrow.core.Either
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model 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.core.ui.utils.DateTimeFormatters
import com.tangem.domain.models.account.TangemPayTariffPlan import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanTransition 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.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.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.formatNextBillingDateOrNull import com.tangem.features.tangempay.tiers.formatNextBillingDateOrNull
@ -41,8 +38,7 @@ internal class TangemPaySelectPlanModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val router: Router, private val router: Router,
private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase, private val getTransitions: GetTangemPayTariffPlanTransitionsUseCase,
private val createTransitionOrder: CreateTariffPlanTransitionOrderUseCase, private val submitTariffTransitionUseCase: SubmitTariffTransitionUseCase,
private val setPendingTransition: SetTariffPlanPendingTransitionUseCase,
private val uiMessageSender: UiMessageSender, private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler, private val analytics: AnalyticsEventHandler,
) : Model() { ) : Model() {
@ -140,31 +136,11 @@ internal class TangemPaySelectPlanModel @Inject constructor(
if (transition.type == TangemPayTariffPlanTransition.Type.UPGRADE) { if (transition.type == TangemPayTariffPlanTransition.Type.UPGRADE) {
analytics.send(TangemPayAnalyticsEvents.Tiers.PlanChangeUpgradeClicked()) 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<VisaApiError, Unit>) {
isProcessing = true isProcessing = true
state.update { buildState() } state.update { buildState() }
modelScope.launch { modelScope.launch {
action().fold( submitTariffTransitionUseCase(params.userWalletId, transition).fold(
ifRight = { ifRight = {
when (params.source) { when (params.source) {
TangemPaySelectPlanSource.TIERS_ONBOARDING -> { TangemPaySelectPlanSource.TIERS_ONBOARDING -> {

View file

@ -14,7 +14,7 @@ internal interface TangemPayDetailIntents {
fun onClickVisaBenefits() fun onClickVisaBenefits()
fun onClickCashback() fun onClickCashback()
fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan)
fun onCancelPlusTransition(orderId: String) fun onCancelTariffTransition(orderId: String)
fun onCardClick(cardId: String) fun onCardClick(cardId: String)
fun onAddCardClick(tariffState: TangemPayTariffPlanState?) fun onAddCardClick(tariffState: TangemPayTariffPlanState?)
fun onRemoveAccount() fun onRemoveAccount()

View file

@ -118,7 +118,7 @@ internal class TangemPayDetailsModelTest {
produceTangemPayInitialDataUseCase = mockk(relaxed = true), produceTangemPayInitialDataUseCase = mockk(relaxed = true),
onboardingRepository = mockk(relaxed = true), onboardingRepository = mockk(relaxed = true),
getCustomerOffers = mockk(relaxed = true), getCustomerOffers = mockk(relaxed = true),
cancelTangemPayOrderUseCase = mockk(relaxed = true), cancelTariffTransitionUseCase = mockk(relaxed = true),
getCashbackSummaryUseCase = mockk(relaxed = true), getCashbackSummaryUseCase = mockk(relaxed = true),
getCashbackDeactivationDismissedUseCase = mockk(relaxed = true), getCashbackDeactivationDismissedUseCase = mockk(relaxed = true),
setCashbackDeactivationDismissedUseCase = mockk(relaxed = true), setCashbackDeactivationDismissedUseCase = mockk(relaxed = true),

View file

@ -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<TangemPayTariffPlanTransition> = 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<TangemPayAnalyticsEvents.Tiers.TierSelectionScreenShowed>())
}
}
@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<TangemPayAnalyticsEvents.Tiers.TiersSwiped>()) }
}
@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<TangemPayAnalyticsEvents.Tiers.TiersSwiped>()) }
}
@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<TangemPayAnalyticsEvents.Tiers.PlanSelectedClick>()) }
verify(exactly = 1) {
analytics.send(ofType<TangemPayAnalyticsEvents.Tiers.PlanChangeConfirmationScreenShowed>())
}
}
@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<TangemPayAnalyticsEvents.Tiers.PlanSelectedClick>()) }
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<TangemPayAnalyticsEvents.Tiers.ComparePlansClicked>()) }
verify(exactly = 1) { analytics.send(ofType<TangemPayAnalyticsEvents.Tiers.PlansComparisonPopupShowed>()) }
}
@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<TangemPayAnalyticsEvents.Tiers.ComparePlansClicked>()) }
}
@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<TangemPayAnalyticsEvents.Tiers.PlansComparisonPopupClosed>()) }
}
@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<TangemPayAnalyticsEvents.Tiers.PlanChangeCancelClicked>()) }
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<TangemPayAnalyticsEvents.Tiers.PlanChangeUpgradeClicked>()) }
}
@Test
fun `GIVEN processing in progress WHEN onCloseClick THEN pop is ignored`() {
// GIVEN
val pending = CompletableDeferred<arrow.core.Either<VisaApiError, Unit>>()
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(),
)
}
}