Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-13 16:07:12 +05:00
parent 71507af6c3
commit 589805424e
9 changed files with 129 additions and 66 deletions

View file

@ -10,6 +10,7 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.OnboardingRepository
@ -19,7 +20,11 @@ import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
private const val TAG = "PaymentAccountStatusFetcher" private const val TAG = "PaymentAccountStatusFetcher"
@ -144,53 +149,103 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
} }
private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue { private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue {
// Step 1: Check KYC status first
val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold(
ifLeft = { error ->
logger.e("proceedWithOrderId KYC check ${account.userWalletId} error: $error")
return error.mapToPaymentAccountStatus()
},
ifRight = { it },
)
logger.i("proceedWithOrderId ${account.userWalletId} kycStatus: ${customerInfo.kycStatus}")
when (customerInfo.kycStatus) {
KycStatus.PENDING,
KycStatus.INIT,
KycStatus.REJECTED,
-> return customerInfo.mapToPaymentAccountStatus()
KycStatus.APPROVED -> Unit // proceed to order check
}
// Step 2: Check order status
return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold( return customerOrderRepository.getOrderData(userWalletId = account.userWalletId, orderId = orderId).fold(
ifLeft = { error -> ifLeft = { error ->
logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error") logger.e("proceedWithOrderId ${account.userWalletId} orderId: $orderId error: $error")
error.mapToPaymentAccountStatus() error.mapToPaymentAccountStatus()
}, },
ifRight = { orderData -> ifRight = { orderData ->
logger.i("proceedWithOrderId $account.userWalletId: $orderId status: ${orderData.status}") logger.i("proceedWithOrderId ${account.userWalletId}: $orderId status: ${orderData.status}")
when (orderData.status) { when (orderData.status) {
// Kyc is passed and user waits for order creation -> no need to get customer info OrderStatus.CANCELED -> handleCanceledOrder(account, orderData)
OrderStatus.COMPLETED -> handleCompletedOrder(account)
OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable
OrderStatus.NEW, OrderStatus.NEW,
OrderStatus.PROCESSING, OrderStatus.PROCESSING,
-> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL) -> {
paymentAccountStatusesStore.store(
OrderStatus.CANCELED -> { userWalletId = account.userWalletId,
onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) status = AccountStatus.Payment(
.fold( account = account,
ifLeft = { value = PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL),
PaymentAccountStatusValue.Error.CardIssueFailed( ),
customerId = orderData.customerId, )
) // Start polling for terminal state
}, pollOrderStatus(account = account, orderId = orderId)
ifRight = { customerInfo ->
if (customerInfo.kycStatus == KycStatus.REJECTED) {
customerInfo.mapToPaymentAccountStatus()
} else {
PaymentAccountStatusValue.Error.CardIssueFailed(
customerId = orderData.customerId,
)
}
},
)
} }
OrderStatus.COMPLETED -> {
// Order was completed -> clear order id and get customer info
onboardingRepository.clearOrderId(account.userWalletId)
onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
)
}
OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable
} }
}, },
) )
} }
private suspend fun pollOrderStatus(account: Account.Payment, orderId: String): PaymentAccountStatusValue {
while (currentCoroutineContext().isActive) {
delay(1.minutes)
val result = customerOrderRepository.getOrderData(
userWalletId = account.userWalletId,
orderId = orderId,
)
result.fold(
ifLeft = { error ->
logger.e("pollOrderStatus ${account.userWalletId} orderId: $orderId error: $error")
// Continue polling on transient errors
},
ifRight = { orderData ->
logger.i("pollOrderStatus ${account.userWalletId}: $orderId status: ${orderData.status}")
when (orderData.status) {
OrderStatus.CANCELED -> return handleCanceledOrder(account, orderData)
OrderStatus.COMPLETED -> return handleCompletedOrder(account)
OrderStatus.NEW,
OrderStatus.PROCESSING,
OrderStatus.UNKNOWN,
-> Unit // Continue polling
}
},
)
}
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
private suspend fun handleCanceledOrder(
account: Account.Payment,
orderData: OrderData,
): PaymentAccountStatusValue {
onboardingRepository.clearOrderId(account.userWalletId)
return PaymentAccountStatusValue.Error.CardIssueFailed(orderData.customerId)
}
private suspend fun handleCompletedOrder(account: Account.Payment): PaymentAccountStatusValue {
onboardingRepository.clearOrderId(account.userWalletId)
return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId)
.fold(
ifLeft = { it.mapToPaymentAccountStatus() },
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
)
}
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue { private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue {
val cardInfo = this.cardInfo val cardInfo = this.cardInfo
val productInstance = this.productInstance val productInstance = this.productInstance

View file

@ -41,8 +41,8 @@ data class AccountList private constructor(
get() = accounts.first { it is Account.CryptoPortfolio && it.isMainAccount } as Account.CryptoPortfolio get() = accounts.first { it is Account.CryptoPortfolio && it.isMainAccount } as Account.CryptoPortfolio
/** Returns true if more accounts can be added (the maximum number of accounts has not been reached) */ /** Returns true if more accounts can be added (the maximum number of accounts has not been reached) */
val canAddMoreAccounts: Boolean val canAddMoreCryptoAccounts: Boolean
get() = accounts.size < MAX_ACCOUNTS_COUNT get() = accounts.filterIsInstance<Account.CryptoPortfolio>().size < MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT
/** Returns the number of active accounts in the list */ /** Returns the number of active accounts in the list */
val activeAccounts: Int val activeAccounts: Int
@ -151,6 +151,11 @@ data class AccountList private constructor(
override fun toString(): String = "$tag: The number of accounts must not exceed 20" override fun toString(): String = "$tag: The number of accounts must not exceed 20"
} }
data object ExceedsMaxPaymentAccountsCount : Error {
override fun toString(): String =
"$tag: The number of payment accounts must not exceed $MAX_PAYMENT_ACCOUNTS_COUNT"
}
@Serializable @Serializable
data object DuplicateAccountIds : Error { data object DuplicateAccountIds : Error {
override fun toString(): String = "$tag: Account list contains duplicate account IDs" override fun toString(): String = "$tag: Account list contains duplicate account IDs"
@ -169,7 +174,8 @@ data class AccountList private constructor(
companion object { companion object {
const val MAX_ACCOUNTS_COUNT = 20 const val MAX_PAYMENT_ACCOUNTS_COUNT = 1
const val MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT = 20
const val MAX_ARCHIVED_ACCOUNTS_COUNT = 1000 const val MAX_ARCHIVED_ACCOUNTS_COUNT = 1000
private const val MAX_MAIN_ACCOUNTS_COUNT = 1 private const val MAX_MAIN_ACCOUNTS_COUNT = 1
@ -191,7 +197,11 @@ data class AccountList private constructor(
): Either<Error, AccountList> = either { ): Either<Error, AccountList> = either {
ensure(accounts.isNotEmpty()) { Error.EmptyAccountsList } ensure(accounts.isNotEmpty()) { Error.EmptyAccountsList }
ensure(accounts.size <= MAX_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount } val paymentAccounts = accounts.filterIsInstance<Account.Payment>()
ensure(paymentAccounts.size <= MAX_PAYMENT_ACCOUNTS_COUNT) { Error.ExceedsMaxPaymentAccountsCount }
val cryptoAccounts = accounts.filterIsInstance<Account.CryptoPortfolio>()
ensure(cryptoAccounts.size <= MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount }
val mainAccountsCount = accounts.mainAccountsCount() val mainAccountsCount = accounts.mainAccountsCount()
ensure(mainAccountsCount == MAX_MAIN_ACCOUNTS_COUNT) { ensure(mainAccountsCount == MAX_MAIN_ACCOUNTS_COUNT) {

View file

@ -45,8 +45,8 @@ internal class AccountListTest {
val fullAccountList = MockAccounts.fullAccountList val fullAccountList = MockAccounts.fullAccountList
// Act & Assert // Act & Assert
Truth.assertThat(accountList.canAddMoreAccounts).isTrue() Truth.assertThat(accountList.canAddMoreCryptoAccounts).isTrue()
Truth.assertThat(fullAccountList.canAddMoreAccounts).isFalse() Truth.assertThat(fullAccountList.canAddMoreCryptoAccounts).isFalse()
} }
@Test @Test

View file

@ -46,7 +46,7 @@ class RecoverCryptoPortfolioUseCase(
val accountList = getAccountList(userWalletId = accountId.userWalletId) val accountList = getAccountList(userWalletId = accountId.userWalletId)
ensure(accountList.canAddMoreAccounts) { ensure(accountList.canAddMoreCryptoAccounts) {
raise(Error.AccountListRequirementsNotMet(cause = AccountList.Error.ExceedsMaxAccountsCount)) raise(Error.AccountListRequirementsNotMet(cause = AccountList.Error.ExceedsMaxAccountsCount))
} }

View file

@ -160,33 +160,30 @@ class WalletBalanceFetcher internal constructor(
paymentAccountRefactorEnabled: Boolean, paymentAccountRefactorEnabled: Boolean,
) { ) {
coroutineScope { coroutineScope {
val errorDeferreds = fetchingSources.map { source -> // Fetch balance sources in parallel
async { val balanceErrors = fetchingSources.filterIsInstance<WalletFetchingSource.Balance>()
when (source) { .map { source ->
is WalletFetchingSource.Balance -> { async {
balanceFetchingOperations.fetchAll( balanceFetchingOperations.fetchAll(
userWalletId = userWalletId, userWalletId = userWalletId,
currencies = currencies, currencies = currencies,
sources = source.sources, sources = source.sources,
).mapKeys { (fetchingSource, _) -> fetchingSource.name } ).mapKeys { (fetchingSource, _) -> fetchingSource.name }
}
is WalletFetchingSource.TangemPay -> {
fetchPaymentAccount(userWalletId, paymentAccountRefactorEnabled)
.leftOrNull()
?.let { error -> mapOf(FetchErrorFormatter.TANGEM_PAY_SOURCE_NAME to error) }
.orEmpty()
}
} }
} }
} .awaitAll()
.fold(emptyMap<String, Throwable>()) { acc, map -> acc + map }
val errors = errorDeferreds.awaitAll().fold(emptyMap<String, Throwable>()) { acc, map -> acc + map } check(balanceErrors.isEmpty()) {
val message = FetchErrorFormatter.formatWalletErrors(userWalletId, balanceErrors)
check(errors.isEmpty()) {
val message = FetchErrorFormatter.formatWalletErrors(userWalletId, errors)
TangemLogger.e(message) TangemLogger.e(message)
message message
} }
// Fetch TangemPay separately — may run long-polling, so it must not block balance error checking
if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) {
fetchPaymentAccount(userWalletId, paymentAccountRefactorEnabled)
}
} }
} }

View file

@ -14,7 +14,7 @@ sealed class WalletFetchingSource {
/** /**
* TangemPay account fetching source. * TangemPay account fetching source.
* Handled separately from standard balance sources via [PaymentAccountStatusFetcher]. * Handled separately from standard balance sources via [com.tangem.domain.pay.flow.PaymentAccountStatusFetcher].
*/ */
data object TangemPay : WalletFetchingSource() data object TangemPay : WalletFetchingSource()

View file

@ -204,7 +204,7 @@ internal class TesterAccountsViewModel @Inject constructor(
var nextIndex = accountList.totalAccounts var nextIndex = accountList.totalAccounts
@Suppress("LoopWithTooManyJumpStatements") // never mind for Tester Menu @Suppress("LoopWithTooManyJumpStatements") // never mind for Tester Menu
while (accountList.canAddMoreAccounts) { while (accountList.canAddMoreCryptoAccounts) {
val derivationIndex = DerivationIndex(nextIndex).getOrNull() ?: break val derivationIndex = DerivationIndex(nextIndex).getOrNull() ?: break
val newAccount = Account.CryptoPortfolio.invoke( val newAccount = Account.CryptoPortfolio.invoke(
@ -246,7 +246,7 @@ internal class TesterAccountsViewModel @Inject constructor(
withContext(dispatchers.default) { withContext(dispatchers.default) {
val updatedAccountList = AccountList.invoke( val updatedAccountList = AccountList.invoke(
userWalletId = accountList.userWalletId, userWalletId = accountList.userWalletId,
accounts = if (possibleToArchive > AccountList.MAX_ACCOUNTS_COUNT - 1) { accounts = if (possibleToArchive > AccountList.MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT - 1) {
listOf(accountList.mainAccount) listOf(accountList.mainAccount)
} else { } else {
accountList.accounts.subList(fromIndex = 0, toIndex = accountList.accounts.size - possibleToArchive) accountList.accounts.subList(fromIndex = 0, toIndex = accountList.accounts.size - possibleToArchive)

View file

@ -97,7 +97,7 @@ internal class AccountItemsDelegate @Inject constructor(
add(header) add(header)
addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder)) addAll(accounts.map(::mapAccount).applySortingOrder(order = accountsOrder))
val isAddAccountEnabled = accounts.size < AccountList.MAX_ACCOUNTS_COUNT val isAddAccountEnabled = accounts.size < AccountList.MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT
val shouldShowDescription = accounts.size > 1 val shouldShowDescription = accounts.size > 1
val isArchivedAccountsEnabled = accountStatusList.accountStatuses.size != accountStatusList.totalAccounts val isArchivedAccountsEnabled = accountStatusList.accountStatuses.size != accountStatusList.totalAccounts
@ -165,7 +165,7 @@ internal class AccountItemsDelegate @Inject constructor(
title = resourceReference(R.string.account_add_limit_dialog_title), title = resourceReference(R.string.account_add_limit_dialog_title),
message = resourceReference( message = resourceReference(
id = R.string.account_add_limit_dialog_description, id = R.string.account_add_limit_dialog_description,
formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()), formatArgs = wrappedList(AccountList.MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT.toString()),
), ),
firstActionBuilder = { firstAction }, firstActionBuilder = { firstAction },
), ),

View file

@ -4,6 +4,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.features.tangempay.entity.TangemPayMainUM
internal class TangemPayHideOnboardingStateTransformer( internal class TangemPayHideOnboardingStateTransformer(
userWalletId: UserWalletId, userWalletId: UserWalletId,
@ -11,7 +12,7 @@ internal class TangemPayHideOnboardingStateTransformer(
override fun transform(prevState: WalletState): WalletState { override fun transform(prevState: WalletState): WalletState {
return if (prevState is WalletState.MultiCurrency.Content) { return if (prevState is WalletState.MultiCurrency.Content) {
prevState.copy(tangemPayState = TangemPayState.Empty) prevState.copy(tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty)
} else { } else {
prevState prevState
} }