Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-29 03:21:00 -07:00
parent fc7b758aff
commit c5d0ba8f58
25 changed files with 448 additions and 133 deletions

View file

@ -53,6 +53,7 @@ class MoshiModule {
.withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status")
.withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card")
.withSubtype(PaymentAccountStatusValueDM.ActiveCard::class.java, "active_card")
.withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account")
.withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(

View file

@ -56,6 +56,12 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "customer_id") val customerId: String,
) : PaymentAccountStatusValueDM
@NameLabel("deactivated_account")
data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
) : PaymentAccountStatusValueDM
@JsonClass(generateAdapter = true)
data class FiatBalanceDM(
@Json(name = "available_balance") val availableBalance: BigDecimal,

View file

@ -1568,6 +1568,8 @@
<string name="tangem_pay_unfreeze_card_success">Your card is unfrozen.</string>
<string name="tangem_pay_withdrawal">Withdrawal</string>
<string name="tangempay_account_unable_to_use_rooted">Unable to use on rooted device</string>
<string name="tangempay_account_deactivated_message_title">Your card was deactivated</string>
<string name="tangempay_account_deactivated_message_subtitle">This was done due to regulatory requirements. Anyway withdrawals are still available.</string>
<string name="tangempay_cancel_kyc">Hide KYC from main screen</string>
<string name="tangempay_card_details_add_funds">Add funds</string>
<string name="tangempay_card_details_add_funds_subtitle">Top-up options</string>
@ -1632,6 +1634,7 @@
<string name="tangempay_kyc_failed_title">Unfortunately, we couldn\'t verify your identity </string>
<string name="tangempay_kyc_has_failed">KYC rejected</string>
<string name="tangempay_kyc_in_progress">KYC in progress</string>
<string name="tangempay_status_deactivated">Card deactivated</string>
<string name="tangempay_kyc_in_progress_notification_button">View Status</string>
<string name="tangempay_kyc_in_progress_notification_title">KYC in progress for Tangem Pay</string>
<string name="tangempay_kyc_in_progress_popup_description">Documents are usually verified automatically within 5 minutes. In rare cases, if manual review is required, it may take up to 48 hours.</string>

View file

@ -12,6 +12,10 @@ android {
namespace = "com.tangem.data.visa"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -68,4 +72,9 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -52,6 +52,9 @@ internal object PaymentAccountStatusValueDMConverter :
customerId = value.customerId,
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
fiatBalance = value.fiatBalance.toDM(),
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.Error.ExposedDevice,
@ -101,6 +104,10 @@ internal object PaymentAccountStatusValueDMConverter :
kycStatus = value.kycStatus,
customerId = value.customerId,
)
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
)
null -> PaymentAccountStatusValue.Error.Unavailable
}
}

View file

@ -48,16 +48,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
val account = Account.Payment(userWalletId = params.userWalletId)
logger.i("fetch: ${params.userWalletId.stringValue}")
if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) {
return@catchOn paymentAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = AccountStatus.Payment(
account = account,
value = PaymentAccountStatusValue.Empty,
),
)
}
if (deviceSecurity.isSecurityExposed()) {
logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}")
logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}")
@ -254,20 +244,31 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) {
convertToContentState(
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
val fiatBalance = fiatBalance
return when {
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && (isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
productInstance = productInstance,
cardInfo = cardInfo,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else {
PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
}
@ -306,7 +307,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId)
is VisaApiError.Deactivated -> constructNotCreatedOrEmptyStatus(userWalletId)
else -> PaymentAccountStatusValue.Error.Unavailable
}
}

View file

@ -2,7 +2,6 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.pay.store.PaymentAccountStatusesStore
@ -11,6 +10,7 @@ import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
@ -101,10 +101,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
val isFormer = result?.state?.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
if (isDeactivated || isFormer) {
tangemPayStorage.storeIsTangemPayDeactivated(userWalletId)
VisaApiError.Deactivated.left()
} else {
getCustomerInfo(userWalletId = userWalletId, response = result).right()
}
getCustomerInfo(userWalletId = userWalletId, response = result).right()
}
}
@ -180,10 +178,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
currencyCode = fiatBalance.currency,
depositAddress = response.depositAddress,
isPinSet = response.card?.isPinSet == true,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = fiatBalance.availableBalance,
currency = fiatBalance.currency,
),
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = cryptoBalance.id,
chainId = cryptoBalance.chainId.toLong(),
@ -202,13 +197,20 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState)
ProductInstance(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
status = instance.status.toDomain(),
)
}
return CustomerInfo(
customerId = response?.id,
productInstance = productInstance,
kycStatus = kycStatus,
cardInfo = cardInfo,
state = response?.state?.let { CustomerInfo.State.fromString(it) } ?: CustomerInfo.State.UNDEFINED,
fiatBalance = fiatBalance?.toDomain(),
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
@ -290,4 +292,24 @@ internal class DefaultOnboardingRepository @Inject constructor(
setHideMainOnboardingBanner(userWalletId)
}
}
}
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
private fun CustomerMeResponse.ProductInstance.Status.toDomain() = when (this) {
CustomerMeResponse.ProductInstance.Status.NEW -> ProductInstance.Status.NEW
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> ProductInstance.Status.READY_FOR_MANUFACTURING
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> ProductInstance.Status.MANUFACTURING
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> ProductInstance.Status.SENT_TO_DELIVERY
CustomerMeResponse.ProductInstance.Status.DELIVERED -> ProductInstance.Status.DELIVERED
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> ProductInstance.Status.ACTIVATING
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
CustomerMeResponse.ProductInstance.Status.BLOCKED -> ProductInstance.Status.BLOCKED
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> ProductInstance.Status.DEACTIVATING
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> ProductInstance.Status.DEACTIVATED
CustomerMeResponse.ProductInstance.Status.CANCELED -> ProductInstance.Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> ProductInstance.Status.UNKNOWN
}

View file

@ -0,0 +1,147 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class PaymentAccountStatusValueDMConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `GIVEN domain Empty WHEN convert THEN returns DM Empty`() {
// GIVEN
val domain = PaymentAccountStatusValue.Empty
// WHEN
val result = PaymentAccountStatusValueDMConverter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.Empty::class.java)
}
@Test
fun `GIVEN domain Deactivated with balance WHEN convert THEN returns DM DeactivatedAccount with same balance`() {
// GIVEN
val domain = PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
)
)
// WHEN
val result = PaymentAccountStatusValueDMConverter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
}
@Test
fun `GIVEN domain Loading WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Loading
// WHEN
val result = PaymentAccountStatusValueDMConverter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain Error Unavailable WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Error.Unavailable
// WHEN
val result = PaymentAccountStatusValueDMConverter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain NotCreated WHEN convert THEN returns DM NotCreated`() {
// GIVEN
val domain = PaymentAccountStatusValue.NotCreated
// WHEN
val result = PaymentAccountStatusValueDMConverter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.NotCreated::class.java)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@Test
fun `GIVEN DM Empty WHEN convertBack THEN returns domain Empty`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.Empty()
// WHEN
val result = PaymentAccountStatusValueDMConverter.convertBack(dm)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.Empty)
}
@Test
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("200"),
currency = "EUR",
)
)
// WHEN
val result = PaymentAccountStatusValueDMConverter.convertBack(dm)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
val deactivated = result as PaymentAccountStatusValue.Deactivated
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
}
@Test
fun `GIVEN null DM WHEN convertBack THEN returns Error Unavailable`() {
// GIVEN / WHEN
val result = PaymentAccountStatusValueDMConverter.convertBack(null)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.Error.Unavailable)
}
@Test
fun `GIVEN DM NotCreated WHEN convertBack THEN returns domain NotCreated`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.NotCreated()
// WHEN
val result = PaymentAccountStatusValueDMConverter.convertBack(dm)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.NotCreated)
}
}
}

View file

@ -56,6 +56,7 @@ class IsAccountsModeEnabledUseCase(
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.Locked,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Deactivated,
-> true
}
}

View file

@ -150,6 +150,21 @@ class IsAccountsModeEnabledUseCaseTest {
Truth.assertThat(actual).isTrue()
}
@Test
fun `GIVEN payment account is Deactivated WHEN invoke THEN returns true`() = runTest {
// GIVEN
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Deactivated>())),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
// WHEN
val actual = useCase.invoke().first()
// THEN
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple status lists and one has two crypto portfolios`() = runTest {
val statusList1 = createAccountStatusList(statuses = listOf(mockCryptoPortfolio()))
@ -270,6 +285,21 @@ class IsAccountsModeEnabledUseCaseTest {
Truth.assertThat(actual).isTrue()
}
@Test
fun `GIVEN payment account is Deactivated WHEN invokeSync THEN returns true`() = runTest {
// GIVEN
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Deactivated>())),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
// WHEN
val actual = useCase.invokeSync()
// THEN
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple status lists and one has loaded payment`() = runTest {
val statusList1 = createAccountStatusList(statuses = listOf(mockCryptoPortfolio()))

View file

@ -27,6 +27,7 @@ sealed class PaymentAccountStatusValue {
is Loading -> TotalFiatBalance.Loading
is Locked -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
}
/**
@ -40,6 +41,7 @@ sealed class PaymentAccountStatusValue {
is Loaded -> copy(source = source)
is Locked -> copy(source = source)
is UnderReview -> copy(source = source)
is Deactivated -> copy(source = source)
is Loading,
is Empty,
is NotCreated,
@ -88,6 +90,18 @@ sealed class PaymentAccountStatusValue {
@Serializable
data class IssuingCard(override val source: StatusSource) : PaymentAccountStatusValue()
/**
* Represents a state where the account is deactivated.
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance details.
*/
@Serializable
data class Deactivated(
override val source: StatusSource,
val fiatBalance: FiatBalance,
) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is locked.
*

View file

@ -11,4 +11,5 @@ data class TangemPayDetailsConfig(
val cardFrozenState: TangemPayCardFrozenState,
val cardNumberEnd: String,
val chainId: Int,
val isTangemPayDeactivated: Boolean,
)

View file

@ -57,7 +57,6 @@ sealed class VisaApiError(
data object CustomerIsBlocked : VisaApiError(104110210)
data object UnknownWithoutCode : VisaApiError(104110999)
data class Unknown(override val errorCode: Int) : VisaApiError(errorCode)
data object Deactivated : VisaApiError(0)
fun isUnknown() = this is UnknownWithoutCode || this is Unknown

View file

@ -23,6 +23,8 @@ data class CustomerInfo(
val productInstance: ProductInstance?,
val kycStatus: KycStatus,
val cardInfo: CardInfo?,
val state: State,
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
) {
enum class State {
NEW,
@ -49,7 +51,23 @@ data class CustomerInfo(
val id: String,
val cardId: String,
val frozenState: TangemPayCardFrozenState,
)
val status: Status,
) {
enum class Status {
NEW,
READY_FOR_MANUFACTURING,
MANUFACTURING,
SENT_TO_DELIVERY,
DELIVERED,
ACTIVATING,
ACTIVE,
BLOCKED,
DEACTIVATING,
DEACTIVATED,
CANCELED,
UNKNOWN,
}
}
data class CardInfo(
val lastFourDigits: String,

View file

@ -6,5 +6,4 @@ sealed interface TangemPayCustomerInfoError {
data object RefreshNeededError : TangemPayCustomerInfoError
data object UnknownError : TangemPayCustomerInfoError
data object ExposedDeviceError : TangemPayCustomerInfoError
data object DeactivatedError : TangemPayCustomerInfoError
}

View file

@ -62,10 +62,6 @@ class TangemPayMainScreenCustomerInfoUseCase(
}
val result = proceedWithPaeraCustomerResult(userWalletId)
if (result.leftOrNull() is TangemPayCustomerInfoError.DeactivatedError) {
updateState(userWalletId, MainCustomerInfoContentState.Empty.right())
return
}
updateState(userWalletId, result.map(MainCustomerInfoContentState::Content))
} else {
// if there's no tangem pay, check eligibility and show onboarding banner
@ -172,7 +168,6 @@ class TangemPayMainScreenCustomerInfoUseCase(
return when (this) {
is VisaApiError.RefreshTokenExpired -> TangemPayCustomerInfoError.RefreshNeededError
is VisaApiError.NotPaeraCustomer -> TangemPayCustomerInfoError.UnknownError
is VisaApiError.Deactivated -> TangemPayCustomerInfoError.DeactivatedError
else -> TangemPayCustomerInfoError.UnavailableError
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
@ -12,6 +13,7 @@ import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.model.transformers.TangemPayCardFrozenStateConverter
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@ -23,51 +25,13 @@ internal class TangemPayDetailsStateFactory(
private val cardFrozenState: TangemPayCardFrozenState,
private val converter: TangemPayCardFrozenStateConverter,
) {
@Suppress("LongMethod")
fun getInitialState(): TangemPayDetailsUM {
val cardFrozenStateItem = when (cardFrozenState) {
is TangemPayCardFrozenState.Pending -> null
is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_unfreeze_card),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickUnfreezeCard,
),
)
is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.FreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_freeze_card),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickFreezeCard,
),
)
}
fun getInitialState(isTangemPayDeactivated: Boolean): TangemPayDetailsUM {
return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = listOfNotNull(
TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.ChangePin,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_pin_code),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickPinCode,
),
),
TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.TermsAndLimits,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangem_pay_terms_limits),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickTermsAndLimits,
),
),
cardFrozenStateItem,
).toPersistentList(),
items = getTopBarMenuItems().takeIf { !isTangemPayDeactivated },
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -92,17 +56,67 @@ internal class TangemPayDetailsStateFactory(
addToWalletBlockState = null,
isBalanceHidden = false,
addFundsEnabled = true,
cardFrozenState = converter.convert(cardFrozenState),
betaNotificationConfig = NotificationConfig(
title = resourceReference(R.string.tangem_pay_beta_notification_title),
subtitle = resourceReference(R.string.tangem_pay_beta_notification_subtitle),
iconResId = R.drawable.img_visa_notification,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_contact_support),
onClick = intents::onContactSupportClicked,
),
iconSize = 36.dp,
),
cardFrozenState = getCardFrozenState().takeIf { !isTangemPayDeactivated },
betaNotificationConfig = getBetaNotificationConfig().takeIf { !isTangemPayDeactivated },
accountDeactivatedNotificationConfig = NotificationConfig(
title = TextReference.Res(R.string.tangempay_account_deactivated_message_title),
subtitle = TextReference.Res(R.string.tangempay_account_deactivated_message_subtitle),
iconResId = R.drawable.img_attention_20,
).takeIf { isTangemPayDeactivated },
)
}
private fun getCardFrozenState() = converter.convert(cardFrozenState)
private fun getBetaNotificationConfig() = NotificationConfig(
title = resourceReference(R.string.tangem_pay_beta_notification_title),
subtitle = resourceReference(R.string.tangem_pay_beta_notification_subtitle),
iconResId = R.drawable.img_visa_notification,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_contact_support),
onClick = intents::onContactSupportClicked,
),
iconSize = 36.dp,
)
private fun getTopBarMenuItems(): ImmutableList<TangemPayDetailsTopBarMenuItem> {
val cardFrozenStateItem = when (cardFrozenState) {
is TangemPayCardFrozenState.Pending -> null
is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_unfreeze_card),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickUnfreezeCard,
),
)
is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.FreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_freeze_card),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickFreezeCard,
),
)
}
return listOfNotNull(
TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.ChangePin,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_pin_code),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickPinCode,
),
),
TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.TermsAndLimits,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangem_pay_terms_limits),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickTermsAndLimits,
),
),
cardFrozenStateItem,
).toPersistentList()
}
}

View file

@ -15,8 +15,9 @@ internal data class TangemPayDetailsUM(
val addToWalletBlockState: AddToWalletBlockState?,
val isBalanceHidden: Boolean,
val addFundsEnabled: Boolean,
val cardFrozenState: CardFrozenState,
val betaNotificationConfig: NotificationConfig,
val cardFrozenState: CardFrozenState?,
val betaNotificationConfig: NotificationConfig?,
val accountDeactivatedNotificationConfig: NotificationConfig?,
)
internal data class TangemPayCardDetailsUM(

View file

@ -55,14 +55,11 @@ import com.tangem.features.tokendetails.ExpressTransactionsEventListener
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -98,7 +95,7 @@ internal class TangemPayDetailsModel @Inject constructor(
)
val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow(stateFactory.getInitialState())
field = MutableStateFlow(stateFactory.getInitialState(params.config.isTangemPayDeactivated))
private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
@ -116,9 +113,11 @@ internal class TangemPayDetailsModel @Inject constructor(
init {
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
handleBalanceHiding()
fetchAddToWalletBanner()
fetchBalance()
subscribeToCardFrozenState()
if (!params.config.isTangemPayDeactivated) {
fetchAddToWalletBanner()
subscribeToCardFrozenState()
}
}
fun onResume() {

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.buttons.HorizontalActionChips
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
@ -86,13 +87,16 @@ internal fun TangemPayDetailsScreen(
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
),
) {
item(TangemPayCardDetailsUM::class.java) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
state = cardDetailsState,
)
if (state.accountDeactivatedNotificationConfig == null) {
item(TangemPayCardDetailsUM::class.java) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
state = cardDetailsState,
)
SpacerH12()
}
}
when (state.cardFrozenState) {
is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) {
@ -100,11 +104,11 @@ internal fun TangemPayDetailsScreen(
modifier = Modifier
.animateItem()
.padding(horizontal = 16.dp)
.padding(top = 12.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card),
onClick = state.cardFrozenState.onUnfreeze,
)
SpacerH12()
}
else -> Unit
}
@ -115,9 +119,9 @@ internal fun TangemPayDetailsScreen(
TangemPayAddToWalletBlock(
state = state.addToWalletBlockState,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12)
.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH12()
},
)
}
@ -127,34 +131,52 @@ internal fun TangemPayDetailsScreen(
TangemPayDetailsBalanceBlock(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = 12.dp)
.fillMaxWidth(),
state = state.balanceBlockState,
isBalanceHidden = state.isBalanceHidden,
)
SpacerH12()
},
)
item(
key = "TANGEM_PAY_IS_IN_BETA",
content = {
TangemPayBetaBlock(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = 12.dp)
.fillMaxWidth(),
config = state.betaNotificationConfig,
)
},
)
with(expressTransactionsComponent) {
expressTransactionsContent(
state = expressState.transactionsToDisplay,
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 12.dp)
.fillMaxWidth(),
if (state.accountDeactivatedNotificationConfig != null) {
item(
key = "DEACTIVATION_MESSAGE",
content = {
Notification(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.accountDeactivatedNotificationConfig,
)
SpacerH12()
},
)
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
if (state.betaNotificationConfig != null) {
item(
key = "TANGEM_PAY_IS_IN_BETA",
content = {
TangemPayBetaBlock(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.betaNotificationConfig,
)
SpacerH12()
},
)
}
if (state.accountDeactivatedNotificationConfig == null) {
with(expressTransactionsComponent) {
expressTransactionsContent(
state = expressState.transactionsToDisplay,
modifier = modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
}
}
}
expressTransactionsBottomSheetState?.content()
@ -349,6 +371,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
iconSize = 36.dp,
),
accountDeactivatedNotificationConfig = null,
),
TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null),
@ -368,6 +391,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
iconSize = 36.dp,
),
accountDeactivatedNotificationConfig = null,
),
),
)

View file

@ -211,6 +211,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
is PaymentAccountStatusValue.Locked,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> null
}
notification?.let(::add)

View file

@ -240,6 +240,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
is PaymentAccountStatusValue.Locked,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> null
}
notification?.let(::add)

View file

@ -80,6 +80,7 @@ internal class TangemPayUpdateInfoStateTransformer(
cardFrozenState = cardFrozenState,
cardNumberEnd = cardInfo.lastFourDigits,
chainId = POLYGON_CHAIN_ID,
isTangemPayDeactivated = false,
),
)
},

View file

@ -56,6 +56,31 @@ internal class TangemPayMainBlockConverter(
is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading
is PaymentAccountStatusValue.Deactivated -> TangemPayMainUM.Content(
subtitle = TextReference.Res(R.string.tangempay_status_deactivated),
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
balance = getBalanceText(
currencyCode = statusValue.fiatBalance.currency,
balance = statusValue.fiatBalance.availableBalance,
),
balanceSubtitle = stringReference("USDC"), // TODO hardcode for now
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
onClick = {
// Dummy config for deactivated account just to open details screen
tangemPayClickIntents.openDetails(
userWalletId = value.account.userWalletId,
config = TangemPayDetailsConfig(
customerId = "",
cardId = "",
isPinSet = false,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
cardNumberEnd = "",
chainId = POLYGON_CHAIN_ID,
isTangemPayDeactivated = true,
),
)
},
)
is PaymentAccountStatusValue.Locked -> TangemPayMainUM.Content(
subtitle = stringReference("*${statusValue.lastFourDigits}"),
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
@ -75,6 +100,7 @@ internal class TangemPayMainBlockConverter(
cardFrozenState = TangemPayCardFrozenState.Frozen,
cardNumberEnd = statusValue.lastFourDigits,
chainId = POLYGON_CHAIN_ID,
isTangemPayDeactivated = false,
),
)
},
@ -98,6 +124,7 @@ internal class TangemPayMainBlockConverter(
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
cardNumberEnd = statusValue.lastFourDigits,
chainId = POLYGON_CHAIN_ID,
isTangemPayDeactivated = false,
),
)
},

View file

@ -65,11 +65,6 @@ internal class TangemPayMainSubscriber @AssistedInject constructor(
TangemPayCustomerInfoError.ExposedDeviceError -> {
stateController.update(TangemPayExposedDeviceTransformer(userWalletId))
}
TangemPayCustomerInfoError.DeactivatedError -> {
stateController.update(
transformer = TangemPayHiddenStateTransformer(userWalletId),
)
}
TangemPayCustomerInfoError.UnknownError -> {
// hide TangemPay block
TangemLogger.e("Failed when loading main screen TangemPay info: $tangemPayError")