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

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