From 172efa81b2f4497d0b21869d39a614157533501f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Jul 2026 07:34:43 -0700 Subject: [PATCH] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 5 +- .../DefaultCustomerOffersRepository.kt | 9 ---- .../DefaultCustomerOffersRepositoryTest.kt | 6 +-- .../account/TangemPayTariffPlanState.kt | 6 ++- .../entity/TangemPayDetailsStateFactory.kt | 26 ++++++++--- .../tangempay/model/TangemPayDetailsModel.kt | 43 ++++++++--------- .../utils/PaymentAccountStatusExt.kt | 3 -- .../tangempay/utils/TangemPayDetailIntents.kt | 3 +- .../utils/TangemPayMessagesFactory.kt | 46 +++++++++++++++++++ .../TangemPayDetailsStateFactoryTest.kt | 40 +++++++++------- 10 files changed, 124 insertions(+), 63 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d30cb8b166..7a482d413d 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -2009,7 +2009,10 @@ Sorry, we couldn\'t verify your profile. Select plan - You can have up to 3 cards. Delete one to add a new card. + Delete one to add a new card + Upgrade plan to have more cards + Cards limit reached for this plan + Upgrade plan Maximum Cards Issued Yes – to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner). Do I have to share my docs? diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt index eb8f689971..f50aa806e6 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepository.kt @@ -1,10 +1,8 @@ package com.tangem.data.pay.repository import arrow.core.Either -import arrow.core.right import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.response.CustomerOffersResponse -import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.Offer import com.tangem.domain.pay.model.OrderType @@ -18,18 +16,11 @@ internal class DefaultCustomerOffersRepository @Inject constructor( private val requestHelper: TangemPayRequestPerformer, ) : CustomerOffersRepository { - private val offersCache = RuntimeSharedStore>>() - override suspend fun getOffers(userWalletId: UserWalletId): Either> { - val key = userWalletId.stringValue - offersCache.getSyncOrNull()?.get(key)?.let { return it.right() } - return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerOffers(authHeader = authHeader) }.map { response -> response.result.map { it.toDomain() } - }.onRight { offers -> - offersCache.update(default = emptyMap()) { it + (key to offers) } } } diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt index 0513c84f3c..14da140a16 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt @@ -57,7 +57,7 @@ internal class DefaultCustomerOffersRepositoryTest { } @Test - fun `GIVEN offers fetched once WHEN getOffers called twice THEN backend is hit only once`() = runTest { + fun `GIVEN offers response WHEN getOffers called twice THEN backend is hit each time`() = runTest { // Arrange val repository = createRepository() @@ -68,11 +68,11 @@ internal class DefaultCustomerOffersRepositoryTest { // Assert assertThat(first.isRight()).isTrue() assertThat(second).isEqualTo(first) - coVerify(exactly = 1) { tangemPayApi.getCustomerOffers(any()) } + coVerify(exactly = 2) { tangemPayApi.getCustomerOffers(any()) } } @Test - fun `GIVEN backend error WHEN getOffers called again THEN error is not cached and backend is hit again`() = runTest { + fun `GIVEN backend error WHEN getOffers called again THEN backend is hit again`() = runTest { // Arrange coEvery { tangemPayApi.getCustomerOffers(any()) } returnsMany listOf( ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanState.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanState.kt index 657ca64400..81002177c7 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanState.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/TangemPayTariffPlanState.kt @@ -26,4 +26,8 @@ data class TangemPayTariffPlanState( @SerialName("toPlan") val toPlan: TangemPayTariffPlan, ) : OrderStep } -} \ No newline at end of file +} + +val TangemPayTariffPlanState.isPlanTransitioningState + get() = order?.step is TangemPayTariffPlanState.OrderStep.AwaitingDeposit || + tariff.status == TangemPayCustomerTariffPlan.Status.TRANSITIONING \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 538a7e2e5b..9dbda12d0f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_document_20 +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.TangemPayTariffPlanState @@ -58,7 +59,7 @@ internal class TangemPayDetailsStateFactory( actionButtons = persistentListOf(), cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( cards = persistentListOf(), - onAddCardClick = intents::onAddCardClick, + onAddCardClick = {}, isAddCardEnabled = false, ), ), @@ -79,6 +80,7 @@ internal class TangemPayDetailsStateFactory( val hasWithdrawableBalance = status.balance.hasWithdrawableAmount val errorNotification = notificationFactory.createErrorConfig(status.error) val awaitingDepositNotification = notificationFactory.createAwaitingDepositConfig(status.tariffPlan) + val fiatBalance = status.balance.fiatBalance return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -90,7 +92,12 @@ internal class TangemPayDetailsStateFactory( isRefreshing = false, onRefresh = intents::onRefreshSwipe, ), - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + isBalanceFlickering = false, + fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(fiatBalance), + isMuted = !isFresh, + isNegative = fiatBalance.availableBalance.signum() < 0, + isInactive = false, actionButtons = getActionButtonsConfig( isAddFundsEnabled = areActionButtonsEnabled, isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, @@ -108,7 +115,7 @@ internal class TangemPayDetailsStateFactory( ) } .toImmutableList(), - onAddCardClick = intents::onAddCardClick, + onAddCardClick = { intents.onAddCardClick(status.tariffPlan) }, isAddCardEnabled = isAddCardEnabled, progressBanner = status.cards.resolveProgressBanner(), ), @@ -127,8 +134,10 @@ internal class TangemPayDetailsStateFactory( else -> null } - fun getDeactivatedState(hasWithdrawableBalance: Boolean): TangemPayDetailsUM { + fun getDeactivatedState(status: PaymentAccountStatusValue.Deactivated): TangemPayDetailsUM { + val hasWithdrawableBalance: Boolean = status.balance.hasWithdrawableAmount val accountDeactivatedNotification = notificationFactory.createAccountDeactivatedConfig() + val fiatBalance = status.balance.fiatBalance return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -140,7 +149,12 @@ internal class TangemPayDetailsStateFactory( isRefreshing = false, onRefresh = intents::onRefreshSwipe, ), - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + isBalanceFlickering = false, + fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(fiatBalance), + isMuted = status.source != StatusSource.ACTUAL, + isNegative = fiatBalance.availableBalance.signum() < 0, + isInactive = false, actionButtons = getActionButtonsConfig( isAddFundsEnabled = true, isWithdrawEnabled = hasWithdrawableBalance, @@ -184,7 +198,7 @@ internal class TangemPayDetailsStateFactory( state = TangemPayCardUiState.InProgress, ), ), - onAddCardClick = intents::onAddCardClick, + onAddCardClick = {}, isAddCardEnabled = false, ), fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(status.fiatBalance), 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 ca2672e524..b8ed603638 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 @@ -23,11 +23,12 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +import com.tangem.domain.models.account.TangemPayTariffPlanState import com.tangem.domain.models.account.VirtualAccountOnramp +import com.tangem.domain.models.account.isPlanTransitioningState import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier @@ -122,9 +123,11 @@ internal class TangemPayDetailsModel @Inject constructor( val uiState: StateFlow field = MutableStateFlow( when { - params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState( - hasWithdrawableBalance = params.initialStatus.balanceOrNull()?.hasWithdrawableAmount == true, - ) + params.initialStatus.value is PaymentAccountStatusValue.Deactivated -> { + stateFactory.getDeactivatedState( + params.initialStatus.value as PaymentAccountStatusValue.Deactivated, + ) + } else -> stateFactory.getLoadingState() }, ) @@ -145,27 +148,13 @@ internal class TangemPayDetailsModel @Inject constructor( .map { it.value } .onEach { state -> when (state) { - is PaymentAccountStatusValue.Deactivated -> { - val balanceTransformer = DetailsBalanceTransformer( - fiatBalance = state.balance.fiatBalance, - isMuted = state.source != StatusSource.ACTUAL, - ) - uiState.update { - balanceTransformer.transform( - stateFactory.getDeactivatedState( - hasWithdrawableBalance = state.balance.hasWithdrawableAmount, - ), - ) - } + is PaymentAccountStatusValue.Deactivated -> uiState.update { + stateFactory.getDeactivatedState(state) } is PaymentAccountStatusValue.Loaded -> { fetchAddToWalletBanner() fetchCashbackBlock() - val balanceTransformer = DetailsBalanceTransformer( - fiatBalance = state.balance.fiatBalance, - isMuted = !state.isFresh, - ) - uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) } + uiState.update { stateFactory.getLoadedState(state) } } is PaymentAccountStatusValue.Inactive -> uiState.update { stateFactory.getInactiveState(state) @@ -468,12 +457,20 @@ internal class TangemPayDetailsModel @Inject constructor( router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId)) } - override fun onAddCardClick() { + override fun onAddCardClick(tariffState: TangemPayTariffPlanState?) { analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked()) modelScope.launch { val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull() if (offer == null) { - uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError()) + val message = if (tariffState != null && tariffState.tariff.plan.isBasicTier) { + TangemPayMessagesFactory.createMaximumCardsForPlanIssuedMessage( + onUpgradeClick = { onClickCurrentPlan(tariffState.tariff) } + .takeIf { !tariffState.isPlanTransitioningState }, + ) + } else { + TangemPayMessagesFactory.createMaximumCardsIssuedMessage() + } + uiMessageSender.send(message) return@launch } analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardPopupShown()) 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 dc0e671474..3c67567d87 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 @@ -27,9 +27,6 @@ internal val AccountStatus.Payment.tariffPlan: TangemPayCustomerTariffPlan? else -> error("TangemPayDetails opened with unsupported status: $v") } -internal val AccountStatus.Payment.isDeactivated: Boolean - get() = value is PaymentAccountStatusValue.Deactivated - internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean get() = source.isActual() && error == null diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt index 9dc97785a8..00b1b9e9c7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -2,6 +2,7 @@ package com.tangem.features.tangempay.utils import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState import com.tangem.domain.models.account.TangemPayCustomerTariffPlan +import com.tangem.domain.models.account.TangemPayTariffPlanState internal interface TangemPayDetailIntents { fun onContactSupportClicked() @@ -15,6 +16,6 @@ internal interface TangemPayDetailIntents { fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) fun onCancelPlusTransition(orderId: String) fun onCardClick(cardId: String) - fun onAddCardClick() + fun onAddCardClick(tariffState: TangemPayTariffPlanState?) fun onRemoveAccount() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 4450dc8764..ad71e73d89 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -187,4 +187,50 @@ internal object TangemPayMessagesFactory { } } } + + fun createMaximumCardsIssuedMessage(): BottomSheetMessage { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_warning_20) { + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning + } + title = resourceReference(R.string.tangempay_maximum_cards_issued_title) + body = resourceReference(R.string.tangempay_maximum_cards_issued_description) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } + + fun createMaximumCardsForPlanIssuedMessage(onUpgradeClick: (() -> Unit)?): BottomSheetMessage { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_warning_20) { + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning + } + title = resourceReference(R.string.tangempay_maximum_cards_issued_for_plan_title) + body = resourceReference(R.string.tangempay_maximum_cards_issued_for_plan_description) + } + if (onUpgradeClick != null) { + secondaryButton { + text = resourceReference(R.string.common_cancel) + onClick { closeBs() } + } + primaryButton { + text = resourceReference(R.string.tangempay_maximum_cards_issued_for_plan_upgrade_btn) + onClick { + onUpgradeClick() + closeBs() + } + } + } else { + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt index d434e48653..c733f01f3d 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt @@ -113,7 +113,7 @@ internal class TangemPayDetailsStateFactoryTest { @Test fun `GIVEN deactivated with positive balance WHEN getDeactivatedState THEN withdraw enabled`() { // Act - val state = factory.getDeactivatedState(hasWithdrawableBalance = true) + val state = factory.getDeactivatedState(deactivatedStatus(availableForWithdrawal = BigDecimal.TEN)) // Assert assertThat(state.addFundsButton.isEnabled).isTrue() @@ -123,7 +123,7 @@ internal class TangemPayDetailsStateFactoryTest { @Test fun `GIVEN deactivated with zero balance WHEN getDeactivatedState THEN withdraw disabled`() { // Act - val state = factory.getDeactivatedState(hasWithdrawableBalance = false) + val state = factory.getDeactivatedState(deactivatedStatus(availableForWithdrawal = BigDecimal.ZERO)) // Assert assertThat(state.addFundsButton.isEnabled).isTrue() @@ -159,22 +159,30 @@ internal class TangemPayDetailsStateFactoryTest { every { source } returns statusSource every { error } returns statusError every { cards } returns statusCards - every { balance } returns PaymentAccountStatusValue.Balance( - fiatBalance = PaymentAccountStatusValue.FiatBalance( - availableBalance = BigDecimal.ZERO, - currency = "USD", - ), - cryptoBalance = PaymentAccountStatusValue.CryptoBalance( - id = "id", - chainId = 1L, - depositAddress = "address", - tokenContractAddress = "contract", - balance = BigDecimal.ZERO, - ), - availableForWithdrawal = availableForWithdrawal, - ) + every { balance } returns balance(availableForWithdrawal) } + private fun deactivatedStatus(availableForWithdrawal: BigDecimal): PaymentAccountStatusValue.Deactivated = + mockk(relaxed = true) { + every { source } returns StatusSource.ACTUAL + every { balance } returns balance(availableForWithdrawal) + } + + private fun balance(availableForWithdrawal: BigDecimal) = PaymentAccountStatusValue.Balance( + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "id", + chainId = 1L, + depositAddress = "address", + tokenContractAddress = "contract", + balance = BigDecimal.ZERO, + ), + availableForWithdrawal = availableForWithdrawal, + ) + internal data class ButtonStateCase( val source: StatusSource, val error: PaymentAccountStatusValue.Error?,