diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index fc81a2e8cb..e4f569d293 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -672,7 +672,10 @@ internal class ChildFactory @Inject constructor( is AppRoute.TangemPayDetails -> { createComponentChild( context = context, - params = TangemPayDetailsContainerComponent.Params(initialStatus = route.status), + params = TangemPayDetailsContainerComponent.Params( + initialStatus = route.status, + initialRoute = route.initialRoute, + ), componentFactory = tangemPayDetailsContainerComponentFactory, ) } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index e65a45a9f9..a647d2425e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -21,6 +21,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId @@ -487,6 +488,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class TangemPayDetails( val status: AccountStatus.Payment, + val initialRoute: TangemPayDetailsInitialRoute = TangemPayDetailsInitialRoute.ACCOUNT_DETAILS, ) : AppRoute(path = "/tangem_pay_details/${status.account}") @Serializable diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 53d5427cf1..78b7bc142d 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -94,6 +94,7 @@ dependencies { // region Project - Features api(projects.features.swap.domain) api(projects.features.virtualAccounts.details.api) + api(projects.features.tangempay.details.api) // endregion // region Project - Libs diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 6d932fd36b..3fa879fce1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -17,11 +17,12 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDat import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayEntryPoint -import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.isSecurityExposed @@ -64,6 +65,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val cardDetailsRepository: TangemPayCardDetailsRepository, private val issueCardRepository: TangemPayIssueCardRepository, private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles, + private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase, ) : PaymentAccountStatusFetcher { @@ -174,17 +176,65 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}") - val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId) - if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) { - // If order id wasn't saved -> start order creation and get customer info - onboardingRepository.createOrder(account.userWalletId) - .onLeft { TangemLogger.withTag(TAG).e("createOrder failed: $it") } - } - status + resolveFinalStatus( + account = account, + customerInfo = customerInfo, + status = customerInfo.mapToPaymentAccountStatus(account.userWalletId), + ) }, ) } + private suspend fun resolveFinalStatus( + account: Account.Payment, + customerInfo: CustomerInfo, + status: PaymentAccountStatusValue, + ): PaymentAccountStatusValue { + val isIssuing = status is PaymentAccountStatusValue.IssuingCard + val isApproved = customerInfo.kycStatus == KycStatus.APPROVED + val userWalletId = account.userWalletId + val tariffPlan = customerInfo.tariffPlan + + if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled) { + if (isIssuing && isApproved) { + // If order id wasn't saved -> start order creation and get customer info + onboardingRepository.createOrder(userWalletId) + .onLeft { logger.e("createOrder failed: $it") } + } + return status + } + + if (!isIssuing || !isApproved) { + return status + } + + if (tariffPlan == null) { + return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) + } + + val hasActiveIssueOrder = issueCardRepository.getIssueOrderIds(userWalletId).isNotEmpty() + return if (hasActiveIssueOrder) { + PaymentAccountStatusValue.Inactive( + source = StatusSource.ACTUAL, + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + tariffPlan = getTangemPayTariffPlanStateUseCase( + userWalletId = userWalletId, + tariff = tariffPlan, + ), + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = tariffPlan.plan.feeCurrencyOrDefault(), + ), + ) + } else { + PaymentAccountStatusValue.AwaitingPlanSelection( + source = StatusSource.ACTUAL, + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + tariffPlan = tariffPlan, + ) + } + } + private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue { // Step 1: Check KYC status first val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold( @@ -286,24 +336,26 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID), )?.value as? QuoteStatus.Data + val customerId = customerId val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED val isFormer = state == CustomerInfo.State.FORMER val fiatBalance = fiatBalance val cryptoBalance = cryptoBalance - + val hasCardData = cards.isNotEmpty() && productInstances.isNotEmpty() + val isTiersPlusPlanEnabled = tangemPayFeatureToggles.isTiersPlusPlanEnabled return when { - kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> { - PaymentAccountStatusValue.UnderReview( - source = StatusSource.ACTUAL, - kycStatus = kycStatus, - customerId = requireNotNull(customerId) { "CustomerId must not be null" }, - ) - } - fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() && - (isDeactivated || isFormer) -> { + customerId.isNullOrEmpty() -> PaymentAccountStatusValue.IssuingCard( + source = StatusSource.ACTUAL, + ) + kycStatus != KycStatus.APPROVED -> PaymentAccountStatusValue.UnderReview( + source = StatusSource.ACTUAL, + kycStatus = kycStatus, + customerId = customerId, + ) + fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> PaymentAccountStatusValue.Deactivated( source = StatusSource.ACTUAL, - customerId = requireNotNull(customerId) { "CustomerId must not be null" }, + customerId = customerId, balance = PaymentAccountStatusValue.Balance( fiatBalance = fiatBalance, cryptoBalance = cryptoBalance, @@ -313,15 +365,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( fiatRate = quotesData?.fiatRate, error = null, ) - } - cards.isNotEmpty() && productInstances.isNotEmpty() && - fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() -> convertToContentState( - userWalletId = userWalletId, - fiatBalance = fiatBalance, - cryptoBalance = cryptoBalance, - fiatRate = quotesData?.fiatRate, - customerId = requireNotNull(customerId) { "CustomerId must not be null" }, - ) + fiatBalance != null && cryptoBalance != null && (hasCardData || isTiersPlusPlanEnabled) -> + convertToContentState( + userWalletId = userWalletId, + fiatBalance = fiatBalance, + cryptoBalance = cryptoBalance, + fiatRate = quotesData?.fiatRate, + customerId = customerId, + ) else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) } } @@ -365,7 +416,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) + if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled && tangemPayCards.isEmpty()) { + return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) + } // Additional-card issuance: the backend omits the new card until it is provisioned, so surface a // placeholder for every locally tracked in-flight issuance order alongside the real cards. @@ -376,6 +429,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( // the previously shown order and append newly seen cards at the end. val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId)) + val allCards = orderedCards + issuingCards + + if (allCards.isEmpty()) { + return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) + } + val virtualAccount = resolveVirtualAccountOnramp(userWalletId) return PaymentAccountStatusValue.Loaded( @@ -384,7 +443,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( depositAddress = cryptoBalance.depositAddress, cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), fiatRate = fiatRate, - cards = orderedCards + issuingCards, + cards = allCards, balance = PaymentAccountStatusValue.Balance( fiatBalance = fiatBalance, cryptoBalance = cryptoBalance, diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt index cd688dc488..3494261c24 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcherTest.kt @@ -7,11 +7,15 @@ import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +import com.tangem.domain.models.account.TangemPayTariffPlan +import com.tangem.domain.models.account.TangemPayTariffPlanState import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory @@ -22,6 +26,7 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -49,6 +54,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk() private val issueCardRepository: TangemPayIssueCardRepository = mockk() private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk() + private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk() private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase = mockk() private val fetcher = DefaultPaymentAccountStatusFetcher( @@ -65,6 +71,7 @@ internal class DefaultPaymentAccountStatusFetcherTest { cardDetailsRepository = cardDetailsRepository, issueCardRepository = issueCardRepository, virtualAccountFeatureToggles = virtualAccountFeatureToggles, + tangemPayFeatureToggles = tangemPayFeatureToggles, getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase, ) @@ -111,27 +118,47 @@ internal class DefaultPaymentAccountStatusFetcherTest { images = emptyList(), ) + private val basicPlan = TangemPayTariffPlan( + id = "plan_basic", + type = TangemPayTariffPlan.Type.BASIC, + name = "Basic", + descriptionItems = emptyList(), + images = emptyList(), + fees = emptyList(), + ) + + private val customerTariffPlan = TangemPayCustomerTariffPlan( + status = TangemPayCustomerTariffPlan.Status.ACTIVE, + plan = basicPlan, + nextBillingAt = null, + pendingPlan = null, + pendingTransitionAt = null, + ) + private fun buildCustomerInfo( productInstances: List = listOf(cardProductInstance), - ) = CustomerInfo( - customerId = "cust_1", - kycStatus = KycStatus.APPROVED, - state = CustomerInfo.State.ACTIVE, - fiatBalance = PaymentAccountStatusValue.FiatBalance( + fiatBalance: PaymentAccountStatusValue.FiatBalance? = PaymentAccountStatusValue.FiatBalance( availableBalance = BigDecimal.TEN, currency = "USD", ), - cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + cryptoBalance: PaymentAccountStatusValue.CryptoBalance? = PaymentAccountStatusValue.CryptoBalance( id = "usdc", chainId = 137L, depositAddress = "0xdeposit", tokenContractAddress = "0xcontract", balance = BigDecimal.TEN, ), + tariffPlan: TangemPayCustomerTariffPlan? = null, + ) = CustomerInfo( + customerId = "cust_1", + kycStatus = KycStatus.APPROVED, + state = CustomerInfo.State.ACTIVE, + fiatBalance = fiatBalance, + cryptoBalance = cryptoBalance, availableForWithdrawal = BigDecimal.TEN, cards = listOf(cardInfo), productInstances = productInstances, - tariffPlan = null, + tariffPlan = tariffPlan, ) @BeforeEach @@ -146,10 +173,14 @@ internal class DefaultPaymentAccountStatusFetcherTest { cardDetailsRepository, issueCardRepository, virtualAccountFeatureToggles, + tangemPayFeatureToggles, + getTangemPayTariffPlanStateUseCase, ) // Relaxed mocks don't need clearing — deviceSecurity, eligibilityManager, paymentAccountStatusesStore // are relaxed and consistent with their relaxed defaults (false, empty, etc.) clearMocks(paymentAccountStatusesStore, answers = false) + // Tiers off by default — legacy auto-order-creation behavior. Individual tests override. + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false } /** @@ -304,4 +335,142 @@ internal class DefaultPaymentAccountStatusFetcherTest { assertThat(loaded.virtualAccount).isNull() } } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class TiersPlanSelection { + + @Test + fun `GIVEN tiers on and KYC approved and no plan order WHEN invoke THEN stores AwaitingPlanSelection`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = emptyList(), + fiatBalance = null, + cryptoBalance = null, + tariffPlan = customerTariffPlan, + ) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true + coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns emptyList() + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + assertThat(storedStatuses.last().value) + .isInstanceOf(PaymentAccountStatusValue.AwaitingPlanSelection::class.java) + coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) } + } + + @Test + fun `GIVEN tiers on and fallback plan is missing WHEN invoke THEN stores IssuingCard without order`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = emptyList(), + fiatBalance = null, + cryptoBalance = null, + tariffPlan = null, + ) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + assertThat(storedStatuses.last().value) + .isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java) + coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) } + } + + @Test + fun `GIVEN tiers on and plan selected but no balance yet WHEN invoke THEN stores Inactive`() = runTest { + // Arrange + val customerInfo = buildCustomerInfo( + productInstances = emptyList(), + fiatBalance = null, + cryptoBalance = null, + tariffPlan = customerTariffPlan, + ) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true + coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1") + coEvery { + getTangemPayTariffPlanStateUseCase(userWalletId = userWalletId, tariff = customerTariffPlan) + } returns TangemPayTariffPlanState(tariff = customerTariffPlan, order = null) + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + assertThat(storedStatuses.last().value) + .isInstanceOf(PaymentAccountStatusValue.Inactive::class.java) + coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) } + } + + @Test + fun `GIVEN tiers off and KYC approved without card WHEN invoke THEN creates order and stays issuing`() = runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = emptyList()) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false + coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1") + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + assertThat(storedStatuses.last().value) + .isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java) + coVerify(exactly = 1) { onboardingRepository.createOrder(userWalletId) } + } + + @Test + fun `GIVEN tiers off and balance without instances but local issue order WHEN invoke THEN stores IssuingCard`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = emptyList()) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false + coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1") + coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1") + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + assertThat(storedStatuses.last().value) + .isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java) + } + + @Test + fun `GIVEN tiers on and balance without instances but local issue order WHEN invoke THEN stores Loaded with placeholder`() = + runTest { + // Arrange + val customerInfo = buildCustomerInfo(productInstances = emptyList()) + stubHappyPath(customerInfo) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true + every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false + coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1") + coEvery { + cardDetailsRepository.getOrderInfo(userWalletId, "order_1") + } returns VisaApiError.UnknownWithoutCode.left() + val storedStatuses = captureStoredStatuses() + + // Act + fetcher.invoke(params) + + // Assert + val loaded = storedStatuses.lastLoaded() + assertThat(loaded.cards).hasSize(1) + assertThat(loaded.cards.single().state).isEqualTo(TangemPayCardState.Issuing) + } + } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlan.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlan.kt index 912bba0988..26acf8e1e2 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlan.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlan.kt @@ -149,4 +149,8 @@ data class TangemPayTariffPlan( } } } +} + +fun TangemPayTariffPlan.feeCurrencyOrDefault(defaultCurrencyCode: String = "USD"): String { + return fees.firstOrNull()?.currency ?: defaultCurrencyCode } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayDetailsInitialRoute.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayDetailsInitialRoute.kt new file mode 100644 index 0000000000..85a0507208 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayDetailsInitialRoute.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.models.pay + +import kotlinx.serialization.Serializable + +/** + * Which inner screen the Tangem Pay should open on when launched. + * + * [ACCOUNT_DETAILS] — the default account/main page. + * [SELECT_PLAN] — the tariff-plan selection screen, used by the "Select plan" entry point on the + * wallet main screen (Tiers). + */ +@Serializable +enum class TangemPayDetailsInitialRoute { + ACCOUNT_DETAILS, + SELECT_PLAN, +} \ No newline at end of file diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt index acbd67b6f7..fae2d9bdb7 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsContainerComponent.kt @@ -3,8 +3,12 @@ package com.tangem.features.tangempay.components import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute interface TangemPayDetailsContainerComponent : ComposableContentComponent { - data class Params(val initialStatus: AccountStatus.Payment) + data class Params( + val initialStatus: AccountStatus.Payment, + val initialRoute: TangemPayDetailsInitialRoute, + ) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index cf7a85c475..d9b0ac7192 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -15,11 +15,13 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanComponent +import com.tangem.features.tangempay.utils.tariffPlan import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -51,10 +53,22 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru key = "tangemPayDetailsInnerStack", source = stackNavigation, serializer = TangemPayAccountDetailsInnerRoute.serializer(), - initialConfiguration = TangemPayAccountDetailsInnerRoute.AccountDetails, + initialConfiguration = resolveInitialConfiguration(), childFactory = ::screenChild, ) + private fun resolveInitialConfiguration(): TangemPayAccountDetailsInnerRoute { + val tariffPlan = params.initialStatus.tariffPlan + return when (params.initialRoute) { + TangemPayDetailsInitialRoute.ACCOUNT_DETAILS -> TangemPayAccountDetailsInnerRoute.AccountDetails + TangemPayDetailsInitialRoute.SELECT_PLAN -> if (tariffPlan != null) { + TangemPayAccountDetailsInnerRoute.SelectPlan(tariffPlan = tariffPlan) + } else { + TangemPayAccountDetailsInnerRoute.AccountDetails + } + } + } + @Composable override fun Content(modifier: Modifier) { val childStack by childStack.subscribeAsState() 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 01b48f8314..56b784437f 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 @@ -56,7 +56,6 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.transformers.* import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute -import com.tangem.features.tangempay.tiers.feeCurrency import com.tangem.features.tangempay.utils.* import com.tangem.features.tokendetails.ExpressTransactionsEvent import com.tangem.features.tokendetails.ExpressTransactionsEventListener diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/TiersUtils.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/TiersUtils.kt index 9f06d7b555..a82b5f1ab4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/TiersUtils.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/tiers/TiersUtils.kt @@ -9,9 +9,6 @@ import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.TangemPayTariffPlan import org.joda.time.format.DateTimeFormatter -val TangemPayTariffPlan.feeCurrency: String? - get() = fees.firstOrNull()?.currency - fun TangemPayTariffPlan.formatRecurringFeeOrNull(): String? { val fee = fees.find { it.type == TangemPayTariffPlan.Fee.Type.RECURRING } ?: return null val currency = getJavaCurrencyByCode(fee.currency) 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 08daf711bf..14b33183a3 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 @@ -50,6 +50,9 @@ internal class TangemPaySelectPlanModel @Inject constructor( private val allowedTransitions: List get() = transitions.filter { it.type in ALLOWED_TYPES } + private val allowedTransitionsForCompare: List + get() = transitions.filter { it.type in ALLOWED_TYPES_FOR_COMPARE } + private var selectedIndex: Int = 0 private var isConfirm: Boolean = false private var isProcessing: Boolean = false @@ -130,7 +133,7 @@ internal class TangemPaySelectPlanModel @Inject constructor( state.update { buildState() } modelScope.launch { action().fold( - ifRight = { router.popTo(TangemPayAccountDetailsInnerRoute.AccountDetails) }, + ifRight = { router.replaceAll(TangemPayAccountDetailsInnerRoute.AccountDetails) }, ifLeft = { isProcessing = false state.update { buildState() } @@ -163,7 +166,7 @@ internal class TangemPaySelectPlanModel @Inject constructor( ) private fun buildCompare(): TangemPaySelectPlanUM.ComparePlans { - val plans = listOf(params.tariffPlan.plan) + transitions.map { it.plan } + val plans = listOf(params.tariffPlan.plan) + allowedTransitionsForCompare.map { it.plan } val orderedTitles = plans .flatMap { plan -> plan.descriptionItems.filter { it.section in COMPARE_SECTIONS } } .sortedWith(compareBy({ it.section.ordinal }, { it.order })) @@ -282,6 +285,10 @@ internal class TangemPaySelectPlanModel @Inject constructor( TangemPayTariffPlanTransition.Type.DOWNGRADE, TangemPayTariffPlanTransition.Type.ACTIVATION, ) + private val ALLOWED_TYPES_FOR_COMPARE = setOf( + TangemPayTariffPlanTransition.Type.UPGRADE, + TangemPayTariffPlanTransition.Type.DOWNGRADE, + ) private val COMPARE_SECTIONS = setOf( TangemPayTariffPlan.Section.CARD_RELATED, TangemPayTariffPlan.Section.PLAN_RELATED, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 514801267b..dc0e671474 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -2,6 +2,7 @@ package com.tangem.features.tangempay.utils 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.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId @@ -17,6 +18,15 @@ internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token else -> error("TangemPayDetails opened with unsupported status: $v") } +internal val AccountStatus.Payment.tariffPlan: TangemPayCustomerTariffPlan? + get() = when (val v = value) { + is PaymentAccountStatusValue.Inactive -> v.tariffPlan.tariff + is PaymentAccountStatusValue.AwaitingPlanSelection -> v.tariffPlan + is PaymentAccountStatusValue.Loaded -> v.tariffPlan?.tariff + is PaymentAccountStatusValue.Deactivated -> null + else -> error("TangemPayDetails opened with unsupported status: $v") + } + internal val AccountStatus.Payment.isDeactivated: Boolean get() = value is PaymentAccountStatusValue.Deactivated 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 fc2c2b505d..db74ac5718 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 @@ -7,6 +7,7 @@ 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.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository @@ -89,7 +90,10 @@ internal class TangemPayDetailsModelTest { every { userWalletId } returns this@TangemPayDetailsModelTest.userWalletId } } - val params = TangemPayDetailsContainerComponent.Params(initialStatus = paymentStatus) + val params = TangemPayDetailsContainerComponent.Params( + initialStatus = paymentStatus, + initialRoute = TangemPayDetailsInitialRoute.ACCOUNT_DETAILS, + ) every { paymentAccountStatusSupplier.invoke(any()) } returns flowOf(paymentStatus) every { cardDetailsRepository.cardFrozenState(any()) } returns flowOf(frozenState) diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index ff6db15c50..8e45001971 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -14,11 +14,13 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager +import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.TangemPayConstants +import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.WalletSelectorListener import com.tangem.features.tangempay.model.transformers.TangemPayOnboardingButtonLoadingTransformer @@ -46,6 +48,7 @@ internal class TangemPayOnboardingModel @Inject constructor( private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase, private val urlOpener: UrlOpener, private val eligibilityManager: TangemPayEligibilityManager, + private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) : Model(), WalletSelectorListener { private val params = paramsContainer.require() @@ -118,7 +121,7 @@ internal class TangemPayOnboardingModel @Inject constructor( .onRight { customerInfo -> when { customerInfo.kycStatus != KycStatus.APPROVED -> { - if (customerInfo.productInstance == null) { + if (shouldCreateOrderBeforeKyc(customerInfo)) { repository.createOrder(userWalletId) .onLeft { error -> TangemLogger.e("Error creating order before KYC: $error") @@ -209,7 +212,7 @@ internal class TangemPayOnboardingModel @Inject constructor( if (customerInfo.kycStatus == KycStatus.APPROVED) { back() } else { - if (customerInfo.productInstance == null) { + if (shouldCreateOrderBeforeKyc(customerInfo)) { repository.createOrder(userWalletId) .onLeft { error -> TangemLogger.e("Error creating order before KYC: $error") @@ -222,6 +225,9 @@ internal class TangemPayOnboardingModel @Inject constructor( } } + private fun shouldCreateOrderBeforeKyc(customerInfo: CustomerInfo): Boolean = + !tangemPayFeatureToggles.isTiersPlusPlanEnabled && customerInfo.productInstance == null + private fun openKyc(userWalletId: UserWalletId) { router.replaceAll( AppRoute.Wallet, diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt index baa2c6dce9..babd3c3066 100644 --- a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt @@ -9,17 +9,23 @@ 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.common.routing.AppRoute import com.tangem.core.error.UniversalError import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager +import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.ui.TangemPayOnboardingScreenState import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery +import io.mockk.every import io.mockk.coVerify import io.mockk.mockk import io.mockk.verify @@ -33,6 +39,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TangemPayOnboardingModelTest { @@ -43,12 +50,14 @@ internal class TangemPayOnboardingModelTest { private val analytics: AnalyticsEventHandler = mockk(relaxed = true) private val produceInitialDataUseCase: ProduceTangemPayInitialDataUseCase = mockk(relaxed = true) private val urlOpener: UrlOpener = mockk(relaxed = true) + private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk() private val deeplink = "tangem://onboard-visa" @BeforeEach fun resetMocks() { clearMocks(router, repository, eligibilityManager, analytics, produceInitialDataUseCase, urlOpener) + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false } @ParameterizedTest @@ -166,6 +175,61 @@ internal class TangemPayOnboardingModelTest { model.onDestroy() } + @Test + fun `GIVEN tiers off and KYC not approved WHEN onboarding starts THEN order created before KYC`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false + coEvery { produceInitialDataUseCase(userWalletId) } returns Unit.right() + coEvery { repository.getCustomerInfo(userWalletId) } returns + buildCustomerInfo(kycStatus = KycStatus.PENDING).right() + coEvery { repository.createOrder(userWalletId) } returns "order_1".right() + + // Act + val model = createModel(TangemPayOnboardingComponent.Params.HotWalletOnboarding(userWalletId)) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { repository.createOrder(userWalletId) } + verify(exactly = 1) { + router.replaceAll(AppRoute.Wallet, AppRoute.Kyc(userWalletId = userWalletId), onComplete = any()) + } + model.onDestroy() + } + + @Test + fun `GIVEN tiers on and KYC not approved WHEN onboarding starts THEN order not created before KYC`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true + coEvery { produceInitialDataUseCase(userWalletId) } returns Unit.right() + coEvery { repository.getCustomerInfo(userWalletId) } returns + buildCustomerInfo(kycStatus = KycStatus.PENDING).right() + + // Act + val model = createModel(TangemPayOnboardingComponent.Params.HotWalletOnboarding(userWalletId)) + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { repository.createOrder(any()) } + verify(exactly = 1) { + router.replaceAll(AppRoute.Wallet, AppRoute.Kyc(userWalletId = userWalletId), onComplete = any()) + } + model.onDestroy() + } + + private fun buildCustomerInfo(kycStatus: KycStatus) = CustomerInfo( + customerId = "cust_1", + productInstances = emptyList(), + cards = emptyList(), + kycStatus = kycStatus, + state = CustomerInfo.State.NEW, + fiatBalance = null, + cryptoBalance = null, + availableForWithdrawal = BigDecimal.ZERO, + tariffPlan = null, + ) + private fun TestScope.createModel(params: TangemPayOnboardingComponent.Params): TangemPayOnboardingModel { val testDispatcher = StandardTestDispatcher(testScheduler) return TangemPayOnboardingModel( @@ -183,6 +247,7 @@ internal class TangemPayOnboardingModelTest { produceInitialDataUseCase = produceInitialDataUseCase, urlOpener = urlOpener, eligibilityManager = eligibilityManager, + tangemPayFeatureToggles = tangemPayFeatureToggles, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index c97cb0afee..45cdbdcd4f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.pay.TangemPayDetailsInitialRoute import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -159,7 +160,11 @@ internal class DefaultWalletRouter @Inject constructor( } override fun openTangemPaySelectPlan(status: AccountStatus.Payment) { - // TODO v_rodionov: [REDACTED_TASK_KEY] Tiers Onboarding - part 2 + val route = AppRoute.TangemPayDetails( + status = status, + initialRoute = TangemPayDetailsInitialRoute.SELECT_PLAN, + ) + router.push(route) } override fun openYieldSupplyBottomSheet(