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_span">your profile.</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_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>

View file

@ -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<Map<String, 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 ->
tangemPayApi.getCustomerOffers(authHeader = authHeader)
}.map { response ->
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
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<CustomerOffersResponse>,

View file

@ -26,4 +26,8 @@ data class TangemPayTariffPlanState(
@SerialName("toPlan") val toPlan: TangemPayTariffPlan,
) : 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.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),

View file

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

View file

@ -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

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

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
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?,