Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 07:34:43 -07:00
parent 3fd066750f
commit 172efa81b2
10 changed files with 124 additions and 63 deletions

View file

@ -2009,7 +2009,10 @@
<string name="tangempay_kyc_rejected_description">Sorry, we couldn\'t verify</string> <string name="tangempay_kyc_rejected_description">Sorry, we couldn\'t verify</string>
<string name="tangempay_kyc_rejected_description_span">your profile.</string> <string name="tangempay_kyc_rejected_description_span">your profile.</string>
<string name="tangempay_main_select_plan">Select plan</string> <string name="tangempay_main_select_plan">Select plan</string>
<string name="tangempay_maximum_cards_issued_description">You can have up to 3 cards. Delete one to add a new card.</string> <string name="tangempay_maximum_cards_issued_description">Delete one to add a new card</string>
<string name="tangempay_maximum_cards_issued_for_plan_description">Upgrade plan to have more cards</string>
<string name="tangempay_maximum_cards_issued_for_plan_title">Cards limit reached for this plan</string>
<string name="tangempay_maximum_cards_issued_for_plan_upgrade_btn">Upgrade plan</string>
<string name="tangempay_maximum_cards_issued_title">Maximum Cards Issued</string> <string name="tangempay_maximum_cards_issued_title">Maximum Cards Issued</string>
<string name="tangempay_newonboard_Q1_body">Yes to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner).</string> <string name="tangempay_newonboard_Q1_body">Yes to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner).</string>
<string name="tangempay_newonboard_Q1_title">Do I have to share my docs?</string> <string name="tangempay_newonboard_Q1_title">Do I have to share my docs?</string>

View file

@ -1,10 +1,8 @@
package com.tangem.data.pay.repository package com.tangem.data.pay.repository
import arrow.core.Either import arrow.core.Either
import arrow.core.right
import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.CustomerOffersResponse 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.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Offer import com.tangem.domain.pay.model.Offer
import com.tangem.domain.pay.model.OrderType import com.tangem.domain.pay.model.OrderType
@ -18,18 +16,11 @@ internal class DefaultCustomerOffersRepository @Inject constructor(
private val requestHelper: TangemPayRequestPerformer, private val requestHelper: TangemPayRequestPerformer,
) : CustomerOffersRepository { ) : CustomerOffersRepository {
private val offersCache = RuntimeSharedStore<Map<String, List<Offer>>>()
override suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>> { override suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>> {
val key = userWalletId.stringValue
offersCache.getSyncOrNull()?.get(key)?.let { return it.right() }
return requestHelper.performRequest(userWalletId) { authHeader -> return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCustomerOffers(authHeader = authHeader) tangemPayApi.getCustomerOffers(authHeader = authHeader)
}.map { response -> }.map { response ->
response.result.map { it.toDomain() } response.result.map { it.toDomain() }
}.onRight { offers ->
offersCache.update(default = emptyMap()) { it + (key to offers) }
} }
} }

View file

@ -57,7 +57,7 @@ internal class DefaultCustomerOffersRepositoryTest {
} }
@Test @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 // Arrange
val repository = createRepository() val repository = createRepository()
@ -68,11 +68,11 @@ internal class DefaultCustomerOffersRepositoryTest {
// Assert // Assert
assertThat(first.isRight()).isTrue() assertThat(first.isRight()).isTrue()
assertThat(second).isEqualTo(first) assertThat(second).isEqualTo(first)
coVerify(exactly = 1) { tangemPayApi.getCustomerOffers(any()) } coVerify(exactly = 2) { tangemPayApi.getCustomerOffers(any()) }
} }
@Test @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 // Arrange
coEvery { tangemPayApi.getCustomerOffers(any()) } returnsMany listOf( coEvery { tangemPayApi.getCustomerOffers(any()) } returnsMany listOf(
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<CustomerOffersResponse>, ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<CustomerOffersResponse>,

View file

@ -26,4 +26,8 @@ data class TangemPayTariffPlanState(
@SerialName("toPlan") val toPlan: TangemPayTariffPlan, @SerialName("toPlan") val toPlan: TangemPayTariffPlan,
) : OrderStep ) : OrderStep
} }
} }
val TangemPayTariffPlanState.isPlanTransitioningState
get() = order?.step is TangemPayTariffPlanState.OrderStep.AwaitingDeposit ||
tariff.status == TangemPayCustomerTariffPlan.Status.TRANSITIONING

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_document_20 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.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState import com.tangem.domain.models.account.TangemPayTariffPlanState
@ -58,7 +59,7 @@ internal class TangemPayDetailsStateFactory(
actionButtons = persistentListOf(), actionButtons = persistentListOf(),
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = persistentListOf(), cards = persistentListOf(),
onAddCardClick = intents::onAddCardClick, onAddCardClick = {},
isAddCardEnabled = false, isAddCardEnabled = false,
), ),
), ),
@ -79,6 +80,7 @@ internal class TangemPayDetailsStateFactory(
val hasWithdrawableBalance = status.balance.hasWithdrawableAmount val hasWithdrawableBalance = status.balance.hasWithdrawableAmount
val errorNotification = notificationFactory.createErrorConfig(status.error) val errorNotification = notificationFactory.createErrorConfig(status.error)
val awaitingDepositNotification = notificationFactory.createAwaitingDepositConfig(status.tariffPlan) val awaitingDepositNotification = notificationFactory.createAwaitingDepositConfig(status.tariffPlan)
val fiatBalance = status.balance.fiatBalance
return TangemPayDetailsUM( return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig( topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack, onBackClick = onBack,
@ -90,7 +92,12 @@ internal class TangemPayDetailsStateFactory(
isRefreshing = false, isRefreshing = false,
onRefresh = intents::onRefreshSwipe, 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( actionButtons = getActionButtonsConfig(
isAddFundsEnabled = areActionButtonsEnabled, isAddFundsEnabled = areActionButtonsEnabled,
isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance,
@ -108,7 +115,7 @@ internal class TangemPayDetailsStateFactory(
) )
} }
.toImmutableList(), .toImmutableList(),
onAddCardClick = intents::onAddCardClick, onAddCardClick = { intents.onAddCardClick(status.tariffPlan) },
isAddCardEnabled = isAddCardEnabled, isAddCardEnabled = isAddCardEnabled,
progressBanner = status.cards.resolveProgressBanner(), progressBanner = status.cards.resolveProgressBanner(),
), ),
@ -127,8 +134,10 @@ internal class TangemPayDetailsStateFactory(
else -> null else -> null
} }
fun getDeactivatedState(hasWithdrawableBalance: Boolean): TangemPayDetailsUM { fun getDeactivatedState(status: PaymentAccountStatusValue.Deactivated): TangemPayDetailsUM {
val hasWithdrawableBalance: Boolean = status.balance.hasWithdrawableAmount
val accountDeactivatedNotification = notificationFactory.createAccountDeactivatedConfig() val accountDeactivatedNotification = notificationFactory.createAccountDeactivatedConfig()
val fiatBalance = status.balance.fiatBalance
return TangemPayDetailsUM( return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig( topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack, onBackClick = onBack,
@ -140,7 +149,12 @@ internal class TangemPayDetailsStateFactory(
isRefreshing = false, isRefreshing = false,
onRefresh = intents::onRefreshSwipe, 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( actionButtons = getActionButtonsConfig(
isAddFundsEnabled = true, isAddFundsEnabled = true,
isWithdrawEnabled = hasWithdrawableBalance, isWithdrawEnabled = hasWithdrawableBalance,
@ -184,7 +198,7 @@ internal class TangemPayDetailsStateFactory(
state = TangemPayCardUiState.InProgress, state = TangemPayCardUiState.InProgress,
), ),
), ),
onAddCardClick = intents::onAddCardClick, onAddCardClick = {},
isAddCardEnabled = false, isAddCardEnabled = false,
), ),
fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(status.fiatBalance), fiatBalance = DetailsBalanceTransformer.getFiatBalanceText(status.fiatBalance),

View file

@ -23,11 +23,12 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan 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.VirtualAccountOnramp
import com.tangem.domain.models.account.isPlanTransitioningState
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
@ -122,9 +123,11 @@ internal class TangemPayDetailsModel @Inject constructor(
val uiState: StateFlow<TangemPayDetailsUM> val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow( field = MutableStateFlow(
when { when {
params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState( params.initialStatus.value is PaymentAccountStatusValue.Deactivated -> {
hasWithdrawableBalance = params.initialStatus.balanceOrNull()?.hasWithdrawableAmount == true, stateFactory.getDeactivatedState(
) params.initialStatus.value as PaymentAccountStatusValue.Deactivated,
)
}
else -> stateFactory.getLoadingState() else -> stateFactory.getLoadingState()
}, },
) )
@ -145,27 +148,13 @@ internal class TangemPayDetailsModel @Inject constructor(
.map { it.value } .map { it.value }
.onEach { state -> .onEach { state ->
when (state) { when (state) {
is PaymentAccountStatusValue.Deactivated -> { is PaymentAccountStatusValue.Deactivated -> uiState.update {
val balanceTransformer = DetailsBalanceTransformer( stateFactory.getDeactivatedState(state)
fiatBalance = state.balance.fiatBalance,
isMuted = state.source != StatusSource.ACTUAL,
)
uiState.update {
balanceTransformer.transform(
stateFactory.getDeactivatedState(
hasWithdrawableBalance = state.balance.hasWithdrawableAmount,
),
)
}
} }
is PaymentAccountStatusValue.Loaded -> { is PaymentAccountStatusValue.Loaded -> {
fetchAddToWalletBanner() fetchAddToWalletBanner()
fetchCashbackBlock() fetchCashbackBlock()
val balanceTransformer = DetailsBalanceTransformer( uiState.update { stateFactory.getLoadedState(state) }
fiatBalance = state.balance.fiatBalance,
isMuted = !state.isFresh,
)
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
} }
is PaymentAccountStatusValue.Inactive -> uiState.update { is PaymentAccountStatusValue.Inactive -> uiState.update {
stateFactory.getInactiveState(state) stateFactory.getInactiveState(state)
@ -468,12 +457,20 @@ internal class TangemPayDetailsModel @Inject constructor(
router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId)) router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId))
} }
override fun onAddCardClick() { override fun onAddCardClick(tariffState: TangemPayTariffPlanState?) {
analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked()) analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked())
modelScope.launch { modelScope.launch {
val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull() val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull()
if (offer == null) { 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 return@launch
} }
analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardPopupShown()) analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardPopupShown())

View file

@ -27,9 +27,6 @@ internal val AccountStatus.Payment.tariffPlan: TangemPayCustomerTariffPlan?
else -> error("TangemPayDetails opened with unsupported status: $v") 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 internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
get() = source.isActual() && error == null get() = source.isActual() && error == null

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.utils
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState
internal interface TangemPayDetailIntents { internal interface TangemPayDetailIntents {
fun onContactSupportClicked() fun onContactSupportClicked()
@ -15,6 +16,6 @@ internal interface TangemPayDetailIntents {
fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan)
fun onCancelPlusTransition(orderId: String) fun onCancelPlusTransition(orderId: String)
fun onCardClick(cardId: String) fun onCardClick(cardId: String)
fun onAddCardClick() fun onAddCardClick(tariffState: TangemPayTariffPlanState?)
fun onRemoveAccount() fun onRemoveAccount()
} }

View file

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

View file

@ -113,7 +113,7 @@ internal class TangemPayDetailsStateFactoryTest {
@Test @Test
fun `GIVEN deactivated with positive balance WHEN getDeactivatedState THEN withdraw enabled`() { fun `GIVEN deactivated with positive balance WHEN getDeactivatedState THEN withdraw enabled`() {
// Act // Act
val state = factory.getDeactivatedState(hasWithdrawableBalance = true) val state = factory.getDeactivatedState(deactivatedStatus(availableForWithdrawal = BigDecimal.TEN))
// Assert // Assert
assertThat(state.addFundsButton.isEnabled).isTrue() assertThat(state.addFundsButton.isEnabled).isTrue()
@ -123,7 +123,7 @@ internal class TangemPayDetailsStateFactoryTest {
@Test @Test
fun `GIVEN deactivated with zero balance WHEN getDeactivatedState THEN withdraw disabled`() { fun `GIVEN deactivated with zero balance WHEN getDeactivatedState THEN withdraw disabled`() {
// Act // Act
val state = factory.getDeactivatedState(hasWithdrawableBalance = false) val state = factory.getDeactivatedState(deactivatedStatus(availableForWithdrawal = BigDecimal.ZERO))
// Assert // Assert
assertThat(state.addFundsButton.isEnabled).isTrue() assertThat(state.addFundsButton.isEnabled).isTrue()
@ -159,22 +159,30 @@ internal class TangemPayDetailsStateFactoryTest {
every { source } returns statusSource every { source } returns statusSource
every { error } returns statusError every { error } returns statusError
every { cards } returns statusCards every { cards } returns statusCards
every { balance } returns PaymentAccountStatusValue.Balance( every { balance } returns balance(availableForWithdrawal)
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.ZERO,
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "id",
chainId = 1L,
depositAddress = "address",
tokenContractAddress = "contract",
balance = BigDecimal.ZERO,
),
availableForWithdrawal = 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( internal data class ButtonStateCase(
val source: StatusSource, val source: StatusSource,
val error: PaymentAccountStatusValue.Error?, val error: PaymentAccountStatusValue.Error?,