Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-24 12:05:48 +05:00
parent f3c2d98214
commit f192c85914
59 changed files with 1679 additions and 432 deletions

View file

@ -33,8 +33,11 @@ dependencies {
api(projects.domain.models)
api(projects.domain.tokens)
api(projects.domain.wallets)
api(projects.domain.visa)
// endregion
implementation(projects.features.tangempay.details.api) // Remove after TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED
// region Project - Data
implementation(projects.data.common)
// endregion
@ -47,6 +50,7 @@ dependencies {
// region Tangem dependencies
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.hot.core)
// endregion
// region DI

View file

@ -1,10 +1,18 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.getOrElse
import arrow.core.none
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -12,6 +20,7 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
/**
* Default implementation of [SingleAccountListProducer].
@ -27,6 +36,8 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@Assisted val params: SingleAccountListProducer.Params,
override val flowProducerTools: FlowProducerTools,
private val walletAccountListFlowFactory: WalletAccountListFlowFactory,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val userWalletsListRepository: UserWalletsListRepository,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountListProducer {
@ -34,8 +45,32 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<AccountList> {
return walletAccountListFlowFactory.create(userWalletId = params.userWalletId)
.flowOn(dispatchers.default)
val accountListFlow: Flow<AccountList> = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) {
combineWithPaymentAccount()
} else {
walletAccountListFlowFactory.create(userWalletId = params.userWalletId)
}
return accountListFlow.flowOn(dispatchers.default)
}
private fun combineWithPaymentAccount(): Flow<AccountList> {
return walletAccountListFlowFactory.create(params.userWalletId)
.map { accountList ->
val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId)
if (userWallet.isPaymentAccountSupported()) {
accountList.plus(Account.Payment(params.userWalletId)).getOrElse { throwable ->
error("Can not combine account list and payment account status: $throwable")
}
} else {
accountList
}
}
}
private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
@AssistedFactory

View file

@ -3,10 +3,12 @@ package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -29,6 +31,10 @@ class DefaultSingleAccountListProducerTest {
private val userWalletId = UserWalletId("011")
private val flowProducerTools: FlowProducerTools = mockk()
private val tangemPayFeatureToggles = mockk<TangemPayFeatureToggles> {
every { this@mockk.isTangemPayAccountsRefactorEnabled } returns false
}
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
@ -38,6 +44,8 @@ class DefaultSingleAccountListProducerTest {
walletAccountListFlowFactory = walletAccountListFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
flowProducerTools = flowProducerTools,
tangemPayFeatureToggles = tangemPayFeatureToggles,
userWalletsListRepository = userWalletsListRepository,
)
@AfterEach

View file

@ -1,67 +0,0 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convertBack
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between [PaymentAccountStatus] and [PaymentAccountStatusDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusDMConverter :
TwoWayConverter<PaymentAccountStatus, PaymentAccountStatusDM?> {
override fun convert(value: PaymentAccountStatus): PaymentAccountStatusDM? {
return when (value) {
is PaymentAccountStatus.NotCreated -> PaymentAccountStatusDM.NotCreated()
is PaymentAccountStatus.UnderReview -> PaymentAccountStatusDM.UnderReview(kycStatus = value.kycStatus)
is PaymentAccountStatus.IssuingCard -> PaymentAccountStatusDM.IssuingCard()
is PaymentAccountStatus.Locked -> PaymentAccountStatusDM.Locked()
is PaymentAccountStatus.Loaded -> PaymentAccountStatusDM.Loaded(
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
is PaymentAccountStatus.Error.CardIssueFailed -> PaymentAccountStatusDM.CardIssueFailed()
// Transient statuses are not persisted
is PaymentAccountStatus.Loading,
is PaymentAccountStatus.Error.ExposedDevice,
is PaymentAccountStatus.Error.Unavailable,
is PaymentAccountStatus.Error.NotSynced,
-> null
}
}
override fun convertBack(value: PaymentAccountStatusDM?): PaymentAccountStatus {
return when (value) {
is PaymentAccountStatusDM.CardIssueFailed -> PaymentAccountStatus.Error.CardIssueFailed
is PaymentAccountStatusDM.NotCreated -> PaymentAccountStatus.NotCreated
is PaymentAccountStatusDM.IssuingCard -> PaymentAccountStatus.IssuingCard(source = StatusSource.CACHE)
is PaymentAccountStatusDM.Locked -> PaymentAccountStatus.Locked(source = StatusSource.CACHE)
is PaymentAccountStatusDM.UnderReview -> PaymentAccountStatus.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
)
is PaymentAccountStatusDM.Loaded -> PaymentAccountStatus.Loaded(
source = StatusSource.CACHE,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
null -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.CACHE)
}
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusValueDMConverter :
TwoWayConverter<PaymentAccountStatusValue, PaymentAccountStatusValueDM?> {
override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? {
return when (value) {
is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated()
is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview(
kycStatus = value.kycStatus,
customerId = value.customerId,
)
is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard()
is PaymentAccountStatusValue.Locked -> PaymentAccountStatusValueDM.ActiveCard(
isLocked = true,
customerId = value.customerId,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
)
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard(
isLocked = false,
customerId = value.customerId,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
)
is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed(
customerId = value.customerId,
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.Error.ExposedDevice,
is PaymentAccountStatusValue.Error.Unavailable,
is PaymentAccountStatusValue.Error.NotSynced,
-> null
}
}
override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
return when (value) {
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed(
customerId = value.customerId,
)
is PaymentAccountStatusValueDM.IssuingCard -> PaymentAccountStatusValue.IssuingCard(
source = StatusSource.CACHE,
)
is PaymentAccountStatusValueDM.ActiveCard -> if (value.isLocked) {
PaymentAccountStatusValue.Locked(
source = StatusSource.CACHE,
customerId = value.customerId,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
)
} else {
PaymentAccountStatusValue.Loaded(
source = StatusSource.CACHE,
customerId = value.customerId,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
)
}
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
customerId = value.customerId,
)
null -> PaymentAccountStatusValue.Error.Unavailable
}
}
private fun PaymentAccountStatusValue.FiatBalance.toDM(): PaymentAccountStatusValueDM.FiatBalanceDM {
return PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = availableBalance,
currency = currency,
)
}
private fun PaymentAccountStatusValue.CryptoBalance.toDM(): PaymentAccountStatusValueDM.CryptoBalanceDM {
return PaymentAccountStatusValueDM.CryptoBalanceDM(
id = id,
chainId = chainId,
depositAddress = depositAddress,
tokenContractAddress = tokenContractAddress,
balance = balance,
)
}
private fun PaymentAccountStatusValueDM.FiatBalanceDM.toDomain(): PaymentAccountStatusValue.FiatBalance {
return PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
}
private fun PaymentAccountStatusValueDM.CryptoBalanceDM.toDomain(): PaymentAccountStatusValue.CryptoBalance {
return PaymentAccountStatusValue.CryptoBalance(
id = id,
chainId = chainId,
depositAddress = depositAddress,
tokenContractAddress = tokenContractAddress,
balance = balance,
)
}
}

View file

@ -15,10 +15,9 @@ import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -32,6 +31,7 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
@ -118,7 +118,7 @@ internal interface TangemPayDataModule {
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<PaymentAccountStatusDM>(),
types = mapWithStringKeyTypes<PaymentAccountStatusValueDM>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },

View file

@ -2,17 +2,19 @@ package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -29,87 +31,101 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
override suspend fun invoke(params: PaymentAccountStatusFetcher.Params): Either<Throwable, Unit> =
eitherOn(dispatchers.default) {
TangemLogger.withTag(TAG).i("fetch: ${params.userWalletId.stringValue}")
Either.catchOn(dispatchers.default) {
val account = Account.Payment(userWalletId = params.userWalletId)
logger.i("fetch: ${params.userWalletId.stringValue}")
if (deviceSecurity.isSecurityExposed()) {
TangemLogger.withTag(TAG).i("fetch security info: rooted: ${deviceSecurity.isRooted}")
TangemLogger.withTag(TAG).i("fetch security info: xposed: ${deviceSecurity.isXposed}")
TangemLogger.withTag(
TAG,
).i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}")
logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}")
logger.i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
return@eitherOn paymentAccountStatusesStore.store(
return@catchOn paymentAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = PaymentAccountStatus.Error.ExposedDevice,
status = AccountStatus.Payment(
account = account,
value = PaymentAccountStatusValue.Error.ExposedDevice,
),
)
}
val status = onboardingRepository.hasTangemPayInWallet(userWalletId = params.userWalletId)
.fold(
ifLeft = { error ->
TangemLogger.withTag(
TAG,
).e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}")
logger.e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}")
when (error) {
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated
else -> PaymentAccountStatusValue.Error.Unavailable
}
},
ifRight = { hasTangemPay ->
proceedHasTangemPayResult(userWalletId = params.userWalletId, hasTangemPay = hasTangemPay)
proceedHasTangemPayResult(account = account, hasTangemPay = hasTangemPay)
},
)
TangemLogger.withTag(TAG).i("invoke status ${params.userWalletId}: $status")
paymentAccountStatusesStore.store(userWalletId = params.userWalletId, status = status)
logger.i("invoke status ${params.userWalletId}: $status")
paymentAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = AccountStatus.Payment(account = account, value = status),
)
}.onLeft {
paymentAccountStatusesStore.updateStatusSource(
userWalletId = params.userWalletId,
source = StatusSource.ONLY_CACHE,
)
}
private suspend fun proceedHasTangemPayResult(
userWalletId: UserWalletId,
account: Account.Payment,
hasTangemPay: Boolean,
): PaymentAccountStatus {
TangemLogger.withTag(TAG).i("proceedHasTangemPayResult for $userWalletId hasTangemPay: $hasTangemPay")
): PaymentAccountStatusValue {
logger.i("proceedHasTangemPayResult for ${account.userWalletId} hasTangemPay: $hasTangemPay")
return if (hasTangemPay) {
fetchTangemPayAccountStatus(userWalletId = userWalletId)
fetchTangemPayAccountStatus(account)
} else {
PaymentAccountStatus.NotCreated
PaymentAccountStatusValue.NotCreated
}
}
private suspend fun fetchTangemPayAccountStatus(userWalletId: UserWalletId): PaymentAccountStatus {
val prevResult = paymentAccountStatusesStore.getSyncOrNull(userWalletId)
if (prevResult == null || prevResult is PaymentAccountStatus.Error) {
paymentAccountStatusesStore.store(userWalletId = userWalletId, status = PaymentAccountStatus.Loading)
private suspend fun fetchTangemPayAccountStatus(account: Account.Payment): PaymentAccountStatusValue {
val prevResult = paymentAccountStatusesStore.getSyncOrNull(account.userWalletId)
if (prevResult == null || prevResult.value is PaymentAccountStatusValue.Error) {
paymentAccountStatusesStore.store(
userWalletId = account.userWalletId,
status = AccountStatus.Payment(account = account, value = PaymentAccountStatusValue.Loading),
)
}
return proceedWithOrderId(userWalletId = userWalletId)
return proceedWithOrderId(account = account)
}
private suspend fun proceedWithOrderId(userWalletId: UserWalletId): PaymentAccountStatus {
return if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) {
PaymentAccountStatus.Error.NotSynced
private suspend fun proceedWithOrderId(account: Account.Payment): PaymentAccountStatusValue {
return if (!onboardingRepository.isTangemPayInitialDataProduced(account.userWalletId)) {
PaymentAccountStatusValue.Error.NotSynced
} else {
val orderId = onboardingRepository.getOrderId(userWalletId)
val orderId = onboardingRepository.getOrderId(account.userWalletId)
if (orderId != null) {
proceedWithOrderId(userWalletId = userWalletId, orderId = orderId)
proceedWithOrderId(account = account, orderId = orderId)
} else {
proceedWithoutOrder(userWalletId = userWalletId)
proceedWithoutOrder(account = account)
}
}
}
private suspend fun proceedWithoutOrder(userWalletId: UserWalletId): PaymentAccountStatus {
return onboardingRepository.getCustomerInfo(userWalletId).fold(
private suspend fun proceedWithoutOrder(account: Account.Payment): PaymentAccountStatusValue {
return onboardingRepository.getCustomerInfo(account.userWalletId).fold(
ifLeft = { error ->
TangemLogger.withTag(TAG).e("proceedWithoutOrder $userWalletId error: $error")
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
error.mapToPaymentAccountStatus()
},
ifRight = { customerInfo ->
TangemLogger.withTag(TAG).i("proceedWithoutOrder data customerInfo $userWalletId")
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
val status = customerInfo.mapToPaymentAccountStatus()
if (customerInfo.productInstance == null) {
onboardingRepository.createOrder(userWalletId)
if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(account.userWalletId)
.onLeft { TangemLogger.withTag(TAG).e("createOrder failed: $it") }
}
status
@ -117,63 +133,94 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
)
}
private suspend fun proceedWithOrderId(userWalletId: UserWalletId, orderId: String): PaymentAccountStatus {
return customerOrderRepository.getOrderData(userWalletId, orderId = orderId).fold(
private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue {
return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold(
ifLeft = { error ->
TangemLogger.withTag(TAG).e("proceedWithOrderId $userWalletId orderId: $orderId error: $error")
logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error")
error.mapToPaymentAccountStatus()
},
ifRight = { orderData ->
TangemLogger.withTag(TAG).i("proceedWithOrderId $userWalletId: $orderId status: ${orderData.status}")
logger.i("proceedWithOrderId $account.userWalletId: $orderId status: ${orderData.status}")
when (orderData.status) {
// Kyc is passed and user waits for order creation -> no need to get customer info
OrderStatus.NEW,
OrderStatus.PROCESSING,
-> PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
-> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
OrderStatus.CANCELED -> {
PaymentAccountStatus.Error.CardIssueFailed
PaymentAccountStatusValue.Error.CardIssueFailed(customerId = orderData.customerId)
}
OrderStatus.COMPLETED -> {
// Order was completed -> clear order id and get customer info
onboardingRepository.clearOrderId(userWalletId)
onboardingRepository.getCustomerInfo(userWalletId = userWalletId)
onboardingRepository.clearOrderId(account.userWalletId)
onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
)
}
OrderStatus.UNKNOWN -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable
}
},
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatus {
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
PaymentAccountStatus.UnderReview(source = StatusSource.ACTUAL, kycStatus = kycStatus)
} else if (cardInfo != null && productInstance != null) {
PaymentAccountStatus.Loaded(
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
cardId = productInstance.cardId,
lastFourDigits = cardInfo.lastFourDigits,
balance = cardInfo.balance,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
isPinSet = cardInfo.isPinSet,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) {
convertToContentState(
productInstance = productInstance,
cardInfo = cardInfo,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else {
PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
}
private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatus {
private fun convertToContentState(
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
): PaymentAccountStatusValue {
return when (productInstance.frozenState) {
TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked(
source = StatusSource.ACTUAL,
customerId = customerId,
cardId = productInstance.cardId,
lastFourDigits = cardInfo.lastFourDigits,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
)
else -> PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
cardId = productInstance.cardId,
lastFourDigits = cardInfo.lastFourDigits,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
isPinSet = cardInfo.isPinSet,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
)
}
}
private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatus.Error.NotSynced
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatusValue.NotCreated
else -> PaymentAccountStatusValue.Error.Unavailable
}
}
}

View file

@ -4,8 +4,9 @@ import arrow.core.Option
import arrow.core.some
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
@ -21,12 +22,15 @@ internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : PaymentAccountStatusProducer {
override val fallback: Option<PaymentAccountStatus>
get() = PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL).some()
override fun produce(): Flow<PaymentAccountStatus> {
private val account = Account.Payment(userWalletId = params.userWalletId)
override val fallback: Option<AccountStatus.Payment>
get() = AccountStatus.Payment(account = account, value = PaymentAccountStatusValue.Error.Unavailable).some()
override fun produce(): Flow<AccountStatus.Payment> {
return paymentAccountStatusesStore.get(userWalletId = params.userWalletId)
.onEmpty { emit(value = PaymentAccountStatus.NotCreated) }
.onEmpty { emit(value = AccountStatus.Payment(account, PaymentAccountStatusValue.NotCreated)) }
.flowOn(dispatchers.default)
}

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.TangemPayEligibilityType
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -139,6 +140,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
?: error("no userWallet found")
}
@Suppress("ComplexCondition")
private suspend fun getCustomerInfo(
userWalletId: UserWalletId,
response: CustomerMeResponse.Result?,
@ -148,14 +150,26 @@ internal class DefaultOnboardingRepository @Inject constructor(
val card = response?.card
val fiatBalance = response?.balance?.fiat
val cryptoBalance = response?.balance?.crypto
val paymentAccount = response?.paymentAccount
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null) {
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = fiatBalance.availableBalance,
currencyCode = fiatBalance.currency,
depositAddress = response.depositAddress,
isPinSet = response.card?.isPinSet == true,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = fiatBalance.availableBalance,
currency = fiatBalance.currency,
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = cryptoBalance.id,
chainId = cryptoBalance.chainId.toLong(),
depositAddress = cryptoBalance.depositAddress.orEmpty(),
tokenContractAddress = cryptoBalance.tokenContractAddress,
balance = cryptoBalance.balance,
),
)
} else {
null
@ -167,7 +181,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
ProductInstance(id = instance.id, cardId = instance.cardId)
ProductInstance(id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState)
}
return CustomerInfo(
customerId = response?.id,

View file

@ -1,11 +1,14 @@
package com.tangem.data.pay.store
import androidx.datastore.core.DataStore
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
@ -14,8 +17,8 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
internal typealias WalletIdWithPaymentStatus = Map<String, PaymentAccountStatus>
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusDM>
internal typealias WalletIdWithPaymentStatus = Map<String, AccountStatus.Payment>
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusValueDM>
/**
* Store for payment account statuses with dual storage (runtime + persistence).
@ -26,7 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatu
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
private val scope: AppCoroutineScope,
scope: AppCoroutineScope,
) {
init {
@ -34,8 +37,10 @@ internal class PaymentAccountStatusesStore(
try {
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.mapValues { (_, statusDM) ->
PaymentAccountStatusDMConverter.convertBack(statusDM)
value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) ->
val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId))
val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM)
AccountStatus.Payment(account = account, value = statusValue)
},
)
} catch (e: Exception) {
@ -44,18 +49,28 @@ internal class PaymentAccountStatusesStore(
}
}
fun get(userWalletId: UserWalletId): Flow<PaymentAccountStatus> {
fun get(userWalletId: UserWalletId): Flow<AccountStatus.Payment> {
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
}
suspend fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Payment? {
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
}
suspend fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
suspend fun updateStatusSource(userWalletId: UserWalletId, source: StatusSource) {
runtimeStore.update(emptyMap()) { stored ->
stored.toMutableMap().apply {
val paymentAccountStatus = this[userWalletId.stringValue] ?: return@update stored
val newValue = paymentAccountStatus.copy(value = paymentAccountStatus.value.copySealed(source = source))
put(key = userWalletId.stringValue, value = newValue)
}
}
}
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Payment) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status.value) }
}
}
@ -63,7 +78,7 @@ internal class PaymentAccountStatusesStore(
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: PaymentAccountStatus) {
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Payment) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
put(key = userWalletId.stringValue, value = status)
@ -71,8 +86,8 @@ internal class PaymentAccountStatusesStore(
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatus) {
val statusDM = PaymentAccountStatusDMConverter.convert(value = status) ?: return
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) {
val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)