Updated on 2026-08-14
This commit is contained in:
commit
d6f9f59866
1729 changed files with 67614 additions and 9361 deletions
|
|
@ -3,11 +3,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
api(projects.domain.common)
|
||||
|
|
@ -23,6 +18,5 @@ dependencies {
|
|||
// region Test libraries
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ data class AccountList private constructor(
|
|||
when (account) {
|
||||
is Account.CryptoPortfolio -> account.cryptoCurrencies
|
||||
is Account.Payment -> emptyList()
|
||||
is Account.Virtual -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +157,12 @@ data class AccountList private constructor(
|
|||
"$tag: The number of payment accounts must not exceed $MAX_PAYMENT_ACCOUNTS_COUNT"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object ExceedsMaxVirtualAccountsCount : Error {
|
||||
override fun toString(): String =
|
||||
"$tag: The number of virtual accounts must not exceed $MAX_VIRTUAL_ACCOUNTS_COUNT"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object DuplicateAccountIds : Error {
|
||||
override fun toString(): String = "$tag: Account list contains duplicate account IDs"
|
||||
|
|
@ -175,6 +182,7 @@ data class AccountList private constructor(
|
|||
companion object {
|
||||
|
||||
const val MAX_PAYMENT_ACCOUNTS_COUNT = 1
|
||||
const val MAX_VIRTUAL_ACCOUNTS_COUNT = 1
|
||||
const val MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT = 20
|
||||
const val MAX_ARCHIVED_ACCOUNTS_COUNT = 1000
|
||||
private const val MAX_MAIN_ACCOUNTS_COUNT = 1
|
||||
|
|
@ -200,6 +208,9 @@ data class AccountList private constructor(
|
|||
val paymentAccounts = accounts.filterIsInstance<Account.Payment>()
|
||||
ensure(paymentAccounts.size <= MAX_PAYMENT_ACCOUNTS_COUNT) { Error.ExceedsMaxPaymentAccountsCount }
|
||||
|
||||
val virtualAccounts = accounts.filterIsInstance<Account.Virtual>()
|
||||
ensure(virtualAccounts.size <= MAX_VIRTUAL_ACCOUNTS_COUNT) { Error.ExceedsMaxVirtualAccountsCount }
|
||||
|
||||
val cryptoAccounts = accounts.filterIsInstance<Account.CryptoPortfolio>()
|
||||
ensure(cryptoAccounts.size <= MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount }
|
||||
|
||||
|
|
|
|||
|
|
@ -63,5 +63,6 @@ fun AccountStatusList.hasMultiCurrencyAccount(): Boolean = accountStatuses.any {
|
|||
when (status) {
|
||||
is AccountStatus.CryptoPortfolio -> status.tokenList.flattenCurrencies().size > 1
|
||||
is AccountStatus.Payment -> false
|
||||
is AccountStatus.Virtual -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,10 @@ abstract class SingleAccountSupplier(
|
|||
override val keyCreator: (SingleAccountProducer.Params) -> String,
|
||||
) : FlowCachingSupplier<SingleAccountProducer, SingleAccountProducer.Params, Account>() {
|
||||
|
||||
operator fun invoke(accountId: AccountId): Flow<Account> {
|
||||
return invoke(params = SingleAccountProducer.Params(accountId))
|
||||
}
|
||||
|
||||
fun filterPaymentAccount(accountId: AccountId): Flow<Account.Payment> {
|
||||
return invoke(params = SingleAccountProducer.Params(accountId)).filterIsInstance()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@ internal class AccountListTest {
|
|||
accounts = createAccounts(count = 21),
|
||||
expected = AccountList.Error.ExceedsMaxAccountsCount.left(),
|
||||
),
|
||||
CreateTestModel(
|
||||
accounts = listOf(
|
||||
Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
Account.Virtual(userWalletId),
|
||||
Account.Virtual(userWalletId),
|
||||
),
|
||||
expected = AccountList.Error.ExceedsMaxVirtualAccountsCount.left(),
|
||||
),
|
||||
CreateTestModel(
|
||||
accounts = listOf(
|
||||
createAccount(derivationIndex = 1),
|
||||
|
|
|
|||
|
|
@ -10,11 +10,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.account.status"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.core)
|
||||
|
|
@ -48,7 +43,6 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// end
|
||||
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@filter false cryptoPortfolio.derivationIndex.value in possibleAccountIndexes }</ID>
|
||||
<ID>MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val cryptoPortfolio = it.account as? Account.CryptoPortfolio ?: return@firstOrNull false cryptoPortfolio.derivationIndex.value == possibleAccountIndex }</ID>
|
||||
<ID>MultilineLambdaItParameter:AccountCryptoCurrencyStatusFinder.kt$AccountCryptoCurrencyStatusFinder${ val currency = it.currency val isContractAddressMatch = contractAddress == null || currency.id.contractAddress.equals(contractAddress, ignoreCase = true) currency.network.rawId == networkId.rawId.value && currency.network.derivationPath.value == derivationPath.value && isContractAddressMatch }</ID>
|
||||
<ID>MultilineLambdaItParameter:ApplyTokenListSortingUseCaseV2.kt$ApplyTokenListSortingUseCaseV2${ errors[account.accountId] = it return@map account }</ID>
|
||||
<ID>MultilineLambdaItParameter:DefaultMultiAccountStatusListProducer.kt$DefaultMultiAccountStatusListProducer${ singleAccountStatusListSupplier( params = SingleAccountStatusListProducer.Params(it.walletId), ) }</ID>
|
||||
<ID>UnnecessaryAbstractClass:MultiAccountStatusListSupplier.kt$MultiAccountStatusListSupplier$MultiAccountStatusListSupplier</ID>
|
||||
<ID>UnnecessaryAbstractClass:SingleAccountStatusListSupplier.kt$SingleAccountStatusListSupplier$SingleAccountStatusListSupplier</ID>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.tangem.domain.account.status.producer
|
|||
import arrow.core.Option
|
||||
import arrow.core.none
|
||||
import arrow.core.toOption
|
||||
import com.tangem.common.card.FirmwareVersion
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.domain.account.models.AccountCurrencyId
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
|
|
@ -16,8 +15,7 @@ import com.tangem.domain.core.utils.lceContent
|
|||
import com.tangem.domain.core.utils.lceLoading
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -44,7 +42,7 @@ import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
|
|||
import com.tangem.domain.tokens.operations.PriceChangeCalculator
|
||||
import com.tangem.domain.tokens.operations.TokenListFactory
|
||||
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
|
||||
import com.tangem.hot.sdk.model.HotWalletId
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -82,6 +80,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
|
|||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val singleAccountListSupplier: SingleAccountListSupplier,
|
||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
private val virtualAccountStatusSupplier: VirtualAccountStatusSupplier,
|
||||
private val networksRepository: NetworksRepository,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val networkStatusSupplier: MultiNetworkStatusSupplier,
|
||||
|
|
@ -92,6 +91,10 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
|
|||
) : SingleAccountStatusListProducer {
|
||||
|
||||
private val logger = TangemLogger.withTag(TAG)
|
||||
private val Account.Payment.errorPaymentAccountStatus: AccountStatus.Payment
|
||||
get() = AccountStatus.Payment(this, PaymentAccountStatusValue.Error.Unavailable)
|
||||
private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual
|
||||
get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable)
|
||||
|
||||
override val fallback: Option<AccountStatusList> = none()
|
||||
|
||||
|
|
@ -143,140 +146,100 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
|
|||
flattenCurrency = flattenCurrency,
|
||||
)
|
||||
|
||||
val isPaymentSupported = userWallet.isPaymentAccountSupported()
|
||||
logger.i("flattenFlow[$walletId]: isPaymentAccountSupported=$isPaymentSupported")
|
||||
if (isPaymentSupported) {
|
||||
combineWithPaymentAccount(
|
||||
accountListFlow = accountListFlow,
|
||||
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
|
||||
paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId)
|
||||
.onEach { paymentStatus ->
|
||||
logger.i(
|
||||
"flattenFlow[$walletId]: paymentAccountStatus emitted " +
|
||||
"valueType=${paymentStatus.value::class.simpleName}",
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
combineWithoutPaymentAccount(
|
||||
accountListFlow = accountListFlow,
|
||||
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
|
||||
)
|
||||
}
|
||||
.collect { accountStatusList -> channel.send(accountStatusList) }
|
||||
}
|
||||
|
||||
private fun combineWithPaymentAccount(
|
||||
accountListFlow: StateFlow<AccountList>,
|
||||
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
|
||||
paymentAccountStatusFlow: Flow<AccountStatus.Payment>,
|
||||
): Flow<AccountStatusList> {
|
||||
return combine(
|
||||
flow = accountListFlow,
|
||||
flow2 = cryptoCurrencyStatusFlow,
|
||||
flow3 = paymentAccountStatusFlow,
|
||||
transform = { accountList, currencyStatusMap, paymentAccountStatus ->
|
||||
logger.i(
|
||||
"combineWithPayment[${params.userWalletId}] transform: " +
|
||||
"accounts=${accountList.accounts.size}, " +
|
||||
"currencyStatusMap=${currencyStatusMap.size}, " +
|
||||
"paymentType=${paymentAccountStatus.value::class.simpleName}",
|
||||
)
|
||||
val accountStatuses = accountList.accounts.map { account ->
|
||||
when (account) {
|
||||
is Account.Payment -> paymentAccountStatus
|
||||
is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) {
|
||||
account.toEmptyAccountStatus()
|
||||
} else {
|
||||
val statuses: List<CryptoCurrencyStatus> =
|
||||
account.cryptoCurrencies.map { currency ->
|
||||
val acId = account.accountId to currency.id
|
||||
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
|
||||
}
|
||||
AccountStatus.CryptoPortfolio(
|
||||
account = account,
|
||||
tokenList = TokenListFactory.create(
|
||||
statuses = statuses,
|
||||
groupType = accountList.groupType,
|
||||
sortType = accountList.sortType,
|
||||
),
|
||||
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
|
||||
val accounts = accountListFlow.value.accounts
|
||||
val hasPaymentAccount = accounts.any { it is Account.Payment }
|
||||
val hasVirtualAccount = accounts.any { it is Account.Virtual }
|
||||
logger.i("flattenFlow[$walletId]: payment=$hasPaymentAccount, virtual=$hasVirtualAccount")
|
||||
val specialStatusFlows = buildList<Flow<AccountStatus>> {
|
||||
if (hasPaymentAccount) {
|
||||
add(
|
||||
paymentAccountStatusSupplier.invoke(userWalletId = walletId)
|
||||
.onEach { status ->
|
||||
logger.i(
|
||||
"flattenFlow[$walletId]: paymentAccountStatus emitted " +
|
||||
"valueType=${status.value::class.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (hasVirtualAccount) {
|
||||
add(
|
||||
virtualAccountStatusSupplier.invoke(userWalletId = walletId)
|
||||
.onEach { status ->
|
||||
logger.i(
|
||||
"flattenFlow[$walletId]: virtualAccountStatus emitted " +
|
||||
"valueType=${status.value::class.simpleName}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val specialStatusesFlow: Flow<Map<AccountId, AccountStatus>> = if (specialStatusFlows.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
combine(specialStatusFlows) { statuses -> statuses.associateBy(AccountStatus::accountId) }
|
||||
}
|
||||
|
||||
combine(
|
||||
accountListFlow,
|
||||
cryptoCurrencyStatusFlow,
|
||||
specialStatusesFlow,
|
||||
) { accountList, currencyStatusMap, specialStatuses ->
|
||||
logger.i(
|
||||
"combine[$walletId] transform:" +
|
||||
"accounts=${accountList.accounts.size}, " +
|
||||
"currencyStatusMap=${currencyStatusMap.size}, " +
|
||||
"specialStatuses=${specialStatuses.size}",
|
||||
)
|
||||
val accountStatuses = accountList.accounts.map { account ->
|
||||
when (account) {
|
||||
is Account.CryptoPortfolio -> buildCryptoPortfolioStatus(account, currencyStatusMap, accountList)
|
||||
is Account.Payment -> specialStatuses[account.accountId] ?: account.errorPaymentAccountStatus
|
||||
is Account.Virtual -> specialStatuses[account.accountId] ?: account.errorVirtualAccountStatus
|
||||
}
|
||||
val balances = accountStatuses.flattenTotalFiatBalance()
|
||||
}
|
||||
buildAccountStatusList(accountList = accountList, accountStatuses = accountStatuses)
|
||||
}.collect { accountStatusList -> channel.send(accountStatusList) }
|
||||
}
|
||||
|
||||
AccountStatusList(
|
||||
userWalletId = accountList.userWalletId,
|
||||
accountStatuses = accountStatuses,
|
||||
totalAccounts = accountList.totalAccounts,
|
||||
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
|
||||
totalArchivedAccounts = accountList.totalArchivedAccounts,
|
||||
sortType = accountList.sortType,
|
||||
groupType = accountList.groupType,
|
||||
)
|
||||
},
|
||||
private fun buildCryptoPortfolioStatus(
|
||||
account: Account.CryptoPortfolio,
|
||||
currencyStatusMap: Map<AccountCurrencyId, CryptoCurrencyStatus>,
|
||||
accountList: AccountList,
|
||||
): AccountStatus.CryptoPortfolio {
|
||||
if (account.cryptoCurrencies.isEmpty()) return account.toEmptyAccountStatus()
|
||||
|
||||
val statuses: List<CryptoCurrencyStatus> = account.cryptoCurrencies.map { currency ->
|
||||
val acId = account.accountId to currency.id
|
||||
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
|
||||
}
|
||||
return AccountStatus.CryptoPortfolio(
|
||||
account = account,
|
||||
tokenList = TokenListFactory.create(
|
||||
statuses = statuses,
|
||||
groupType = accountList.groupType,
|
||||
sortType = accountList.sortType,
|
||||
),
|
||||
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
|
||||
)
|
||||
}
|
||||
|
||||
private fun combineWithoutPaymentAccount(
|
||||
accountListFlow: StateFlow<AccountList>,
|
||||
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
|
||||
): Flow<AccountStatusList> {
|
||||
return combine(
|
||||
flow = accountListFlow,
|
||||
flow2 = cryptoCurrencyStatusFlow,
|
||||
transform = { accountList, currencyStatusMap ->
|
||||
logger.i(
|
||||
"combineWithoutPayment[${params.userWalletId}] transform: " +
|
||||
"accounts=${accountList.accounts.size}, " +
|
||||
"currencyStatusMap=${currencyStatusMap.size}",
|
||||
)
|
||||
val accountStatuses = accountList.accounts
|
||||
.filterIsInstance<Account.CryptoPortfolio>()
|
||||
.map { account ->
|
||||
when (account) {
|
||||
is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) {
|
||||
account.toEmptyAccountStatus()
|
||||
} else {
|
||||
val statuses: List<CryptoCurrencyStatus> =
|
||||
account.cryptoCurrencies.map { currency ->
|
||||
val acId = account.accountId to currency.id
|
||||
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
|
||||
}
|
||||
AccountStatus.CryptoPortfolio(
|
||||
account = account,
|
||||
tokenList = TokenListFactory.create(
|
||||
statuses = statuses,
|
||||
groupType = accountList.groupType,
|
||||
sortType = accountList.sortType,
|
||||
),
|
||||
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val balances = accountStatuses.flattenTotalFiatBalance()
|
||||
|
||||
AccountStatusList(
|
||||
userWalletId = accountList.userWalletId,
|
||||
accountStatuses = accountStatuses,
|
||||
totalAccounts = accountList.totalAccounts,
|
||||
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
|
||||
totalArchivedAccounts = accountList.totalArchivedAccounts,
|
||||
sortType = accountList.sortType,
|
||||
groupType = accountList.groupType,
|
||||
)
|
||||
},
|
||||
private fun buildAccountStatusList(
|
||||
accountList: AccountList,
|
||||
accountStatuses: List<AccountStatus>,
|
||||
): AccountStatusList {
|
||||
val balances = accountStatuses.flattenTotalFiatBalance()
|
||||
return AccountStatusList(
|
||||
userWalletId = accountList.userWalletId,
|
||||
accountStatuses = accountStatuses,
|
||||
totalAccounts = accountList.totalAccounts,
|
||||
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
|
||||
totalArchivedAccounts = accountList.totalArchivedAccounts,
|
||||
sortType = accountList.sortType,
|
||||
groupType = accountList.groupType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) {
|
||||
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
|
||||
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
|
||||
}
|
||||
|
||||
private fun ProducerScope<AccountStatusList>.flattenCurrencyStatusFlow(
|
||||
userWallet: UserWallet,
|
||||
flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>,
|
||||
|
|
@ -410,6 +373,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
|
|||
when (accountStatus) {
|
||||
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
|
||||
is AccountStatus.Payment -> accountStatus.value.totalFiatBalance
|
||||
is AccountStatus.Virtual -> accountStatus.value.totalFiatBalance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -435,6 +399,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
|
|||
)
|
||||
}
|
||||
is Account.Payment -> null
|
||||
is Account.Virtual -> null
|
||||
}
|
||||
},
|
||||
totalAccounts = accountList.totalAccounts,
|
||||
|
|
|
|||
27
domain/address-book/build.gradle.kts
Normal file
27
domain/address-book/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.domain.addressbook"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
|
||||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.tokens)
|
||||
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
|
||||
// region Test libraries
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.domain.addressbook.error
|
||||
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface ContactNameValidationError {
|
||||
|
||||
@Serializable
|
||||
data class Format(val error: ContactName.Error) : ContactNameValidationError
|
||||
|
||||
/** Another contact in the same wallet already uses this name (case-insensitive). */
|
||||
@Serializable
|
||||
data object Duplicate : ContactNameValidationError
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.domain.addressbook.error
|
||||
|
||||
import com.tangem.domain.transaction.error.AddressValidation
|
||||
|
||||
sealed interface SaveContactError {
|
||||
|
||||
data class Name(val error: ContactNameValidationError) : SaveContactError
|
||||
|
||||
data class Address(val error: AddressValidation.Error) : SaveContactError
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import com.tangem.domain.models.network.Network
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A single saved address belonging to a [Contact]. */
|
||||
@Serializable
|
||||
data class AddressEntry(
|
||||
val id: AddressEntryId,
|
||||
val address: String,
|
||||
val networkId: Network.RawID,
|
||||
val memo: String?,
|
||||
val signature: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Client-generated UUID v4 identifier of an [AddressEntry]. */
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class AddressEntryId(val value: String)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A named address stored in the user's address book for fast access when sending. */
|
||||
@Serializable
|
||||
data class Contact(
|
||||
val id: ContactId,
|
||||
val walletId: UserWalletId,
|
||||
val name: ContactName,
|
||||
val addressEntries: List<AddressEntry>,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Client-generated UUID v4 identifier of a [Contact]. */
|
||||
@Serializable
|
||||
@JvmInline
|
||||
value class ContactId(val value: String)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Validated name of a [Contact].
|
||||
*
|
||||
* The only way to obtain an instance is the validating [invoke] factory, which enforces the
|
||||
* address-book naming rules. Uniqueness within a wallet is **not** enforced here — it requires
|
||||
* access to the repository and lives in `ValidateContactNameUseCase`.
|
||||
*/
|
||||
@Serializable
|
||||
@ConsistentCopyVisibility
|
||||
data class ContactName private constructor(val value: String) {
|
||||
|
||||
@Serializable
|
||||
sealed interface Error {
|
||||
|
||||
@Serializable
|
||||
data object Empty : Error
|
||||
|
||||
@Serializable
|
||||
data object ExceedsMaxLength : Error
|
||||
|
||||
@Serializable
|
||||
data object InvalidCharacters : Error
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val MIN_LENGTH = 1
|
||||
const val MAX_LENGTH = 50
|
||||
|
||||
/** Letters, numbers and spaces only — forbids emoji, new lines, tabs, special symbols and html/scripts. */
|
||||
private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$")
|
||||
|
||||
operator fun invoke(value: String): Either<Error, ContactName> = either {
|
||||
val trimmed = value.trim()
|
||||
|
||||
ensure(trimmed.length >= MIN_LENGTH) { Error.Empty }
|
||||
ensure(trimmed.length <= MAX_LENGTH) { Error.ExceedsMaxLength }
|
||||
ensure(allowedPattern.matches(trimmed)) { Error.InvalidCharacters }
|
||||
|
||||
ContactName(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.domain.addressbook.repository
|
||||
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Persistence port for the address book. The implementation is provided by the data layer. */
|
||||
interface AddressBookRepository {
|
||||
|
||||
fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>>
|
||||
|
||||
/** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */
|
||||
fun getContacts(userWalletIds: Set<UserWalletId>): Flow<List<Contact>>
|
||||
|
||||
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
|
||||
|
||||
/** Inserts or updates a [contact]. */
|
||||
suspend fun saveContact(contact: Contact)
|
||||
|
||||
suspend fun deleteContact(id: ContactId)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique
|
||||
* (case-insensitive) within the wallet.
|
||||
*/
|
||||
class CreateContactUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
private val validateContactName: ValidateContactNameUseCase,
|
||||
) {
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
name: String,
|
||||
network: Network,
|
||||
addressEntries: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
.mapLeft(SaveContactError::Name)
|
||||
.bind()
|
||||
|
||||
val contact = Contact(
|
||||
id = ContactId(UUID.randomUUID().toString()),
|
||||
walletId = userWalletId,
|
||||
name = validName,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
repository.saveContact(contact)
|
||||
contact
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
|
||||
class DeleteContactUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(id: ContactId) = repository.deleteContact(id)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class GetContactsUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletIds: Set<UserWalletId>): Flow<List<Contact>> = repository.getContacts(userWalletIds)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
|
||||
/**
|
||||
* Updates an existing [Contact], preserving its contact id. The name is only format-checked —
|
||||
* uniqueness is not re-validated on update. Address entries must be prepared and validated before
|
||||
* calling this use case.
|
||||
*/
|
||||
class UpdateContactUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
contact: Contact,
|
||||
name: String,
|
||||
addressEntries: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) }
|
||||
.bind()
|
||||
|
||||
val updated = contact.copy(
|
||||
name = validName,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
repository.saveContact(updated)
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
|
||||
import com.tangem.domain.transaction.error.AddressValidation
|
||||
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
|
||||
|
||||
/**
|
||||
* Validates a contact's address for a network, reusing the transaction-layer validation. Self-send
|
||||
* is allowed since saving one's own address in the book is valid.
|
||||
*/
|
||||
class ValidateContactAddressUseCase(
|
||||
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase,
|
||||
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
network: Network,
|
||||
address: String,
|
||||
): Either<AddressValidation.Error, Unit> = either {
|
||||
val senderAddresses = getNetworkAddressesUseCase.invokeSync(
|
||||
userWalletId = userWalletId,
|
||||
networkRawId = network.id.rawId,
|
||||
)
|
||||
validateWalletAddressUseCase(
|
||||
userWalletId = userWalletId,
|
||||
network = network,
|
||||
address = address,
|
||||
senderAddresses = senderAddresses,
|
||||
allowSelfSend = true,
|
||||
).bind()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within
|
||||
* the wallet.
|
||||
*/
|
||||
class ValidateContactNameUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
walletId: UserWalletId,
|
||||
name: String,
|
||||
): Either<ContactNameValidationError, ContactName> = either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft(ContactNameValidationError::Format)
|
||||
.bind()
|
||||
|
||||
val contacts = repository.getContacts(walletId).first()
|
||||
val isDuplicate = contacts.any { contact ->
|
||||
contact.name.value.equals(validName.value, ignoreCase = true)
|
||||
}
|
||||
ensure(!isDuplicate) { ContactNameValidationError.Duplicate }
|
||||
|
||||
validName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ContactNameTest {
|
||||
|
||||
@Test
|
||||
fun `valid name is accepted and trimmed`() {
|
||||
val result = ContactName(" Alice 1 ")
|
||||
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Alice 1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single character name is accepted`() {
|
||||
assertThat(ContactName("A").isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `name of max length is accepted`() {
|
||||
val name = "a".repeat(ContactName.MAX_LENGTH)
|
||||
|
||||
assertThat(ContactName(name).isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank name is rejected as Empty`() {
|
||||
assertThat(ContactName(" ").leftOrNull()).isEqualTo(ContactName.Error.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `name exceeding max length is rejected`() {
|
||||
val name = "a".repeat(ContactName.MAX_LENGTH + 1)
|
||||
|
||||
assertThat(ContactName(name).leftOrNull()).isEqualTo(ContactName.Error.ExceedsMaxLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emoji is rejected`() {
|
||||
assertThat(ContactName("Alice 😀").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new line is rejected`() {
|
||||
assertThat(ContactName("Ali\nce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tab is rejected`() {
|
||||
assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html script is rejected`() {
|
||||
assertThat(ContactName("<script>alert(1)</script>").leftOrNull())
|
||||
.isEqualTo(ContactName.Error.InvalidCharacters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special symbols are rejected`() {
|
||||
assertThat(ContactName("Alice@!").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class CreateContactUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = CreateContactUseCase(
|
||||
repository = repository,
|
||||
validateContactName = ValidateContactNameUseCase(repository),
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val networkRawId = Network.RawID("ethereum")
|
||||
private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None)
|
||||
private val network: Network = mockk { every { id } returns networkId }
|
||||
|
||||
private val addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-1"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig",
|
||||
),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create generates ids and persists the contact`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
val result = useCase(
|
||||
userWalletId = walletId,
|
||||
name = "Alice",
|
||||
network = network,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
|
||||
val contact = result.getOrNull()
|
||||
assertThat(contact).isEqualTo(saved.captured)
|
||||
assertThat(contact!!.walletId).isEqualTo(walletId)
|
||||
assertThat(contact.name.value).isEqualTo("Alice")
|
||||
assertThat(contact.id.value).isNotEmpty()
|
||||
assertThat(contact.addressEntries).isEqualTo(addressEntries)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate name fails without persisting`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(
|
||||
userWalletId = walletId,
|
||||
name = "alice",
|
||||
network = network,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid name fails without persisting`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
|
||||
val result = useCase(
|
||||
userWalletId = walletId,
|
||||
name = "",
|
||||
network = network,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class UpdateContactUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = UpdateContactUseCase(
|
||||
repository = repository,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val networkRawId = Network.RawID("ethereum")
|
||||
|
||||
private val updatedEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-new"),
|
||||
address = "0xnew",
|
||||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig2",
|
||||
),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update preserves id and persists changes without checking uniqueness`() = runTest {
|
||||
val existing = contact(name = "Alice")
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
val result = useCase(
|
||||
contact = existing,
|
||||
name = "Bob",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
val contact = result.getOrNull()
|
||||
assertThat(contact).isEqualTo(saved.captured)
|
||||
assertThat(contact!!.id).isEqualTo(existing.id)
|
||||
assertThat(contact.name.value).isEqualTo("Bob")
|
||||
assertThat(contact.addressEntries).isEqualTo(updatedEntries)
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid name fails without persisting`() = runTest {
|
||||
val result = useCase(
|
||||
contact = contact(name = "Alice"),
|
||||
name = "",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.network.CryptoCurrencyAddress
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
|
||||
import com.tangem.domain.transaction.error.AddressValidation
|
||||
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ValidateContactAddressUseCaseTest {
|
||||
|
||||
private val validateWalletAddressUseCase: ValidateWalletAddressUseCase = mockk()
|
||||
private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk()
|
||||
private val useCase = ValidateContactAddressUseCase(
|
||||
validateWalletAddressUseCase = validateWalletAddressUseCase,
|
||||
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
|
||||
)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val networkRawId = Network.RawID("ethereum")
|
||||
private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None)
|
||||
private val network: Network = mockk { every { id } returns networkId }
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(validateWalletAddressUseCase, getNetworkAddressesUseCase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid address forwards sender addresses and allows self-send`() = runTest {
|
||||
val senderAddresses = listOf<CryptoCurrencyAddress>(mockk())
|
||||
coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns senderAddresses
|
||||
coEvery {
|
||||
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
|
||||
} returns AddressValidation.Success.Valid.right()
|
||||
|
||||
val result = useCase(walletId, network, "0xabc")
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify {
|
||||
validateWalletAddressUseCase(
|
||||
userWalletId = walletId,
|
||||
network = network,
|
||||
address = "0xabc",
|
||||
senderAddresses = senderAddresses,
|
||||
allowSelfSend = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid address propagates the validation error`() = runTest {
|
||||
coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns emptyList()
|
||||
coEvery {
|
||||
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
|
||||
} returns AddressValidation.Error.InvalidAddress.left()
|
||||
|
||||
val result = useCase(walletId, network, "bad")
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.AddressEntryId
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ValidateContactNameUseCaseTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val useCase = ValidateContactNameUseCase(repository)
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format error is propagated`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
|
||||
val result = useCase(walletId, name = "")
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate name in same wallet is rejected case-insensitively`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "alice")
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique name is accepted`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
val result = useCase(walletId, name = "Bob")
|
||||
|
||||
assertThat(result.getOrNull()?.value).isEqualTo("Bob")
|
||||
}
|
||||
|
||||
private fun contact(name: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -7,11 +7,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.card"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.error)
|
||||
|
|
@ -37,8 +32,6 @@ dependencies {
|
|||
}
|
||||
|
||||
/** Testing libraries */
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testRuntimeOnly(deps.test.junit5.vintage.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -10,9 +10,7 @@ interface CardRepository {
|
|||
|
||||
suspend fun startCardActivation(cardId: String)
|
||||
|
||||
suspend fun finishCardActivation(cardId: String)
|
||||
|
||||
suspend fun finishCardsActivation(cardIds: List<String>)
|
||||
suspend fun finishCardActivation(cardId: String, hasBackupError: Boolean = false)
|
||||
|
||||
@Throws
|
||||
suspend fun isActivationStarted(cardId: String): Boolean
|
||||
|
|
@ -23,6 +21,9 @@ interface CardRepository {
|
|||
@Throws
|
||||
suspend fun isActivationInProgress(cardId: String): Boolean
|
||||
|
||||
@Throws
|
||||
suspend fun hasBackupError(cardId: String): Boolean
|
||||
|
||||
@Throws
|
||||
suspend fun isTangemTOSAccepted(): Boolean
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.tangem.domain.card.configs
|
|||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.card.EllipticCurve
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class Wallet2CardConfigTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ dependencies {
|
|||
implementation(deps.kotlin.serialization)
|
||||
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import io.mockk.mockk
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
|
|
|
|||
|
|
@ -7,11 +7,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.dynamicaddresses"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.dynamicAddresses.models)
|
||||
|
|
@ -26,7 +21,6 @@ dependencies {
|
|||
}
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -16,7 +16,8 @@ dependencies {
|
|||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import io.mockk.every
|
|||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CheckHotWalletUpgradeBannerUseCaseTest {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import io.mockk.coEvery
|
|||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CloseHotWalletUpgradeBannerUseCaseTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.features"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
|
@ -46,7 +41,6 @@ dependencies {
|
|||
|
||||
/** Testing libraries */
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import com.google.common.truth.Truth
|
|||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BlockchainTests {
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ dependencies {
|
|||
testImplementation(projects.core.pagination)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package com.tangem.domain.managetokens
|
|||
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import com.tangem.pagination.Batch
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@ plugins {
|
|||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.core)
|
||||
api(projects.core.utils)
|
||||
|
|
@ -24,5 +19,4 @@ dependencies {
|
|||
implementation(deps.arrow.core)
|
||||
|
||||
testImplementation(projects.test.core)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
}
|
||||
|
|
@ -13,11 +13,7 @@
|
|||
<ID>BooleanPropertyNaming:YieldBalanceItem.kt$PendingAction.PendingActionArgs.TronResource$val required: Boolean</ID>
|
||||
<ID>CastNullableToNonNullableType:DerivationPathAdapterWithMigration.kt$DerivationPathAdapterWithMigration$as</ID>
|
||||
<ID>MultilineLambdaItParameter:MobileWallet.kt$MobileWallet${ ExtendedPublicKey( publicKey = publicKey, chainCode = it, ) }</ID>
|
||||
<ID>NoNameShadowing:Account.kt$Account.CryptoPortfolio.Companion$derivationIndex</ID>
|
||||
<ID>NullableBooleanCheck:CryptoCurrency.kt$CryptoCurrency$iconUrl?.isNotBlank() ?: true</ID>
|
||||
<ID>NullableToStringCall:AccountName.kt$AccountName.Error.Empty$${Empty::class.simpleName}</ID>
|
||||
<ID>NullableToStringCall:AccountName.kt$AccountName.Error.ExceedsMaxLength$${ExceedsMaxLength::class.simpleName}</ID>
|
||||
<ID>NullableToStringCall:DerivationIndex.kt$DerivationIndex.Error.NegativeDerivationIndex$${this::class.simpleName}</ID>
|
||||
<ID>UnsafeCallOnNullableType:MobileWalletAsStringSerializer.kt$MobileWalletAsStringSerializer$moshi.adapter(MobileWallet::class.java).fromJson(decoder.decodeString())!!</ID>
|
||||
<ID>UnsafeCallOnNullableType:ScanResponseAsStringSerializer.kt$ScanResponseAsStringSerializer$moshi.adapter(ScanResponse::class.java).fromJson(decoder.decodeString())!!</ID>
|
||||
<ID>UseEmptyCounterpart:ScanResponse.kt$ScanResponse$mapOf()</ID>
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ sealed interface Account {
|
|||
}
|
||||
|
||||
@Serializable
|
||||
data class Payment(
|
||||
data class Payment private constructor(
|
||||
override val accountId: AccountId,
|
||||
) : Account {
|
||||
override val accountName: AccountName.Custom = AccountName.Custom("Payment").getOrElse {
|
||||
|
|
@ -189,10 +189,26 @@ sealed interface Account {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Virtual private constructor(
|
||||
override val accountId: AccountId,
|
||||
) : Account {
|
||||
override val accountName: AccountName.Custom = AccountName.Custom("Virtual").getOrElse {
|
||||
error("Can not create account name for Virtual account with userWalletId = ${accountId.userWalletId}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
operator fun invoke(userWalletId: UserWalletId): Virtual {
|
||||
return Virtual(accountId = AccountId.forVirtualAccount(userWalletId = userWalletId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Account.derivationIndex: DerivationIndex?
|
||||
get() = when (this) {
|
||||
is Account.CryptoPortfolio -> derivationIndex
|
||||
is Account.Payment -> null
|
||||
is Account.Virtual -> null
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ data class AccountId private constructor(
|
|||
companion object {
|
||||
|
||||
const val PaymentAccountIdPrefix = "payment_"
|
||||
const val VirtualAccountIdPrefix = "virtual_"
|
||||
|
||||
private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") }
|
||||
private val hexRegex = Regex("^[a-fA-F0-9]{64}$")
|
||||
|
|
@ -77,5 +78,9 @@ data class AccountId private constructor(
|
|||
fun forPaymentAccount(userWalletId: UserWalletId): AccountId {
|
||||
return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId)
|
||||
}
|
||||
|
||||
fun forVirtualAccount(userWalletId: UserWalletId): AccountId {
|
||||
return AccountId(value = "$VirtualAccountIdPrefix$userWalletId", userWalletId = userWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,12 @@ sealed interface AccountStatus {
|
|||
override val account: Account.Payment,
|
||||
val value: PaymentAccountStatusValue,
|
||||
) : AccountStatus
|
||||
|
||||
@Serializable
|
||||
data class Virtual(
|
||||
override val account: Account.Virtual,
|
||||
val value: VirtualAccountStatusValue,
|
||||
) : AccountStatus
|
||||
}
|
||||
|
||||
fun Iterable<AccountStatus>.filterCryptoPortfolio(): List<AccountStatus.CryptoPortfolio> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.domain.models.account
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue.Loaded
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue.Deactivated
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
|
|
@ -33,11 +34,11 @@ sealed class PaymentAccountStatusValue {
|
|||
is Loading -> TotalFiatBalance.Loading
|
||||
is Loaded -> {
|
||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
}
|
||||
is Deactivated -> {
|
||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
TotalFiatBalance.Loaded(amount = balance.fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,12 +47,12 @@ sealed class PaymentAccountStatusValue {
|
|||
*
|
||||
* @param source The new source of the status information.
|
||||
*/
|
||||
fun copySealed(source: StatusSource): PaymentAccountStatusValue {
|
||||
fun copySealed(source: StatusSource, error: Error? = null): PaymentAccountStatusValue {
|
||||
return when (this) {
|
||||
is IssuingCard -> copy(source = source)
|
||||
is Loaded -> copy(source = source)
|
||||
is Loaded -> copy(source = source, error = error ?: this.error)
|
||||
is UnderReview -> copy(source = source)
|
||||
is Deactivated -> copy(source = source)
|
||||
is Deactivated -> copy(source = source, error = error ?: this.error)
|
||||
is Loading,
|
||||
is Empty,
|
||||
is NotCreated,
|
||||
|
|
@ -104,28 +105,31 @@ sealed class PaymentAccountStatusValue {
|
|||
* Represents a state where the account is deactivated.
|
||||
*
|
||||
* @property source The source of the status information.
|
||||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property customerId The unique identifier of the customer.
|
||||
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
|
||||
* @property cryptoCurrency The crypto currency held by the deactivated account.
|
||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||
* or `null` if the quote is not yet available. When `null`,
|
||||
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
|
||||
* @property error Transient error overlaid on top of cached data when a refresh fails
|
||||
* (see [copySealed]), or `null` when the status is up to date. Not persisted.
|
||||
*/
|
||||
@Serializable
|
||||
data class Deactivated(
|
||||
override val source: StatusSource,
|
||||
val fiatBalance: FiatBalance,
|
||||
val cryptoBalance: CryptoBalance,
|
||||
val customerId: String,
|
||||
val balance: Balance,
|
||||
val cryptoCurrency: CryptoCurrency.Token,
|
||||
val fiatRate: SerializedBigDecimal?,
|
||||
val error: Error?,
|
||||
) : PaymentAccountStatusValue() {
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = cryptoCurrency,
|
||||
value = buildCryptoCurrencyStatusValue(
|
||||
amount = cryptoBalance.balance,
|
||||
fiatAmount = fiatBalance.availableBalance,
|
||||
amount = balance.cryptoBalance.balance,
|
||||
fiatAmount = balance.fiatBalance.availableBalance,
|
||||
fiatRate = fiatRate,
|
||||
depositAddress = cryptoBalance.depositAddress,
|
||||
depositAddress = balance.cryptoBalance.depositAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -135,37 +139,35 @@ sealed class PaymentAccountStatusValue {
|
|||
*
|
||||
* @property source The source of the status information.
|
||||
* @property customerId The unique identifier of the customer.
|
||||
* @property currencyCode The code of the currency.
|
||||
* @property depositAddress The address for deposits, if available.
|
||||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
|
||||
* @property balance The balance details (fiat, crypto and amount available for withdrawal).
|
||||
* The fiat currency code is available via [Balance.fiatBalance].
|
||||
* @property cryptoCurrency The crypto currency held by the account.
|
||||
* @property cards The list of user's cards.
|
||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||
* or `null` if the quote is not yet available. When `null`,
|
||||
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
|
||||
* @property error Transient error overlaid on top of cached data when a refresh fails
|
||||
* (see [copySealed]), or `null` when the status is up to date. Not persisted.
|
||||
*/
|
||||
@Serializable
|
||||
data class Loaded(
|
||||
override val source: StatusSource,
|
||||
val customerId: String,
|
||||
val currencyCode: String,
|
||||
val depositAddress: String?,
|
||||
val fiatBalance: FiatBalance,
|
||||
val cryptoBalance: CryptoBalance,
|
||||
val availableForWithdrawal: SerializedBigDecimal,
|
||||
val balance: Balance,
|
||||
val cryptoCurrency: CryptoCurrency.Token,
|
||||
val cards: List<TangemPayCard>,
|
||||
val fiatRate: SerializedBigDecimal?,
|
||||
val error: Error?,
|
||||
) : PaymentAccountStatusValue() {
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = cryptoCurrency,
|
||||
value = buildCryptoCurrencyStatusValue(
|
||||
amount = availableForWithdrawal,
|
||||
fiatAmount = fiatBalance.availableBalance,
|
||||
amount = balance.availableForWithdrawal,
|
||||
fiatAmount = balance.fiatBalance.availableBalance,
|
||||
fiatRate = fiatRate,
|
||||
depositAddress = cryptoBalance.depositAddress,
|
||||
depositAddress = balance.cryptoBalance.depositAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -202,6 +204,21 @@ sealed class PaymentAccountStatusValue {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all balance data of a payment account, as returned by the `customer/me` endpoint.
|
||||
*
|
||||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap
|
||||
* (excludes pending/locked funds).
|
||||
*/
|
||||
@Serializable
|
||||
data class Balance(
|
||||
val fiatBalance: FiatBalance,
|
||||
val cryptoBalance: CryptoBalance,
|
||||
val availableForWithdrawal: SerializedBigDecimal,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the fiat balance of the payment account.
|
||||
*
|
||||
|
|
@ -268,6 +285,8 @@ private fun buildCryptoCurrencyStatusValue(
|
|||
}
|
||||
}
|
||||
|
||||
fun PaymentAccountStatusValue.hasAccountData(): Boolean = this is Loaded || this is Deactivated
|
||||
|
||||
fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId }
|
||||
|
||||
fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
package com.tangem.domain.models.account
|
||||
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.serialization.SerializedBigDecimal
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Represents the various states a virtual account (VA) can have, encapsulating different information based on
|
||||
* the state. Mirrors [PaymentAccountStatusValue] but carries VA-specific states (no card-related variants).
|
||||
*
|
||||
* @property source The source of the status information.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class VirtualAccountStatusValue {
|
||||
abstract val source: StatusSource
|
||||
|
||||
/** The total fiat balance associated with this status. */
|
||||
val totalFiatBalance: TotalFiatBalance
|
||||
get() = when (this) {
|
||||
is Empty,
|
||||
is NotCreated,
|
||||
is UnderReview,
|
||||
is Provisioning,
|
||||
is CountryNotSupported,
|
||||
is Error,
|
||||
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
|
||||
is Loading -> TotalFiatBalance.Loading
|
||||
is Active -> {
|
||||
val rate = fiatRate ?: return TotalFiatBalance.Failed
|
||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the status with a new [source].
|
||||
*
|
||||
* @param source The new source of the status information.
|
||||
*/
|
||||
fun copySealed(source: StatusSource): VirtualAccountStatusValue {
|
||||
return when (this) {
|
||||
is UnderReview -> copy(source = source)
|
||||
is Provisioning -> copy(source = source)
|
||||
is Active -> copy(source = source)
|
||||
is Loading,
|
||||
is Empty,
|
||||
is NotCreated,
|
||||
is CountryNotSupported,
|
||||
is Error,
|
||||
-> this
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents an empty virtual account status when no specific state is available. */
|
||||
@Serializable
|
||||
data object Empty : VirtualAccountStatusValue() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** Represents the Loading state of a virtual account, typically while fetching its details. */
|
||||
@Serializable
|
||||
data object Loading : VirtualAccountStatusValue() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** Represents a state where the virtual account has not been created yet. */
|
||||
@Serializable
|
||||
data object NotCreated : VirtualAccountStatusValue() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a state where the virtual account is under review (KYC).
|
||||
*
|
||||
* @property source The source of the status information.
|
||||
* @property kycStatus The current KYC status.
|
||||
* @property customerId The unique identifier of the customer.
|
||||
*/
|
||||
@Serializable
|
||||
data class UnderReview(
|
||||
override val source: StatusSource,
|
||||
val kycStatus: KycStatus,
|
||||
val customerId: String,
|
||||
) : VirtualAccountStatusValue()
|
||||
|
||||
/**
|
||||
* Represents a state where the virtual account is being provisioned on the backend (e.g. via Rain),
|
||||
* after KYC approval and terms acceptance.
|
||||
*
|
||||
* @property source The source of the status information.
|
||||
*/
|
||||
@Serializable
|
||||
data class Provisioning(override val source: StatusSource) : VirtualAccountStatusValue()
|
||||
|
||||
/** Represents a state where the user's country is not eligible for a virtual account. */
|
||||
@Serializable
|
||||
data object CountryNotSupported : VirtualAccountStatusValue() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a state where the virtual account is successfully loaded with complete information.
|
||||
*
|
||||
* @property source The source of the status information.
|
||||
* @property customerId The unique identifier of the customer.
|
||||
* @property currencyCode The code of the currency.
|
||||
* @property depositAddress The on-chain address for deposits, if available.
|
||||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap.
|
||||
* @property cryptoCurrency The crypto currency held in the account (e.g. USDC).
|
||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the app's selected fiat currency,
|
||||
* or `null` if the quote is not yet available. When `null`,
|
||||
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
|
||||
*/
|
||||
@Serializable
|
||||
data class Active(
|
||||
override val source: StatusSource,
|
||||
val customerId: String,
|
||||
val currencyCode: String,
|
||||
val depositAddress: String?,
|
||||
val fiatBalance: FiatBalance,
|
||||
val cryptoBalance: CryptoBalance,
|
||||
val availableForWithdrawal: SerializedBigDecimal,
|
||||
val cryptoCurrency: CryptoCurrency.Token,
|
||||
val fiatRate: SerializedBigDecimal?,
|
||||
) : VirtualAccountStatusValue() {
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = cryptoCurrency,
|
||||
value = buildCryptoCurrencyStatusValue(
|
||||
amount = availableForWithdrawal,
|
||||
fiatAmount = fiatBalance.availableBalance,
|
||||
fiatRate = fiatRate,
|
||||
depositAddress = cryptoBalance.depositAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Represents an error state for the virtual account status. */
|
||||
@Serializable
|
||||
sealed class Error : VirtualAccountStatusValue() {
|
||||
/** Error state indicating the device is exposed. */
|
||||
@Serializable
|
||||
data object ExposedDevice : Error() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** Error state indicating the account is unavailable. */
|
||||
@Serializable
|
||||
data object Unavailable : Error() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** Error state indicating the account data is not synced. */
|
||||
@Serializable
|
||||
data object NotSynced : Error() {
|
||||
override val source: StatusSource = StatusSource.ACTUAL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the fiat balance of the virtual account.
|
||||
*
|
||||
* @property availableBalance The amount of available balance in fiat.
|
||||
* @property currency The currency of the balance.
|
||||
*/
|
||||
@Serializable
|
||||
data class FiatBalance(val availableBalance: SerializedBigDecimal, val currency: String)
|
||||
|
||||
/**
|
||||
* Represents the crypto balance of the virtual account.
|
||||
*
|
||||
* @property id The unique identifier of the crypto asset.
|
||||
* @property chainId The identifier of the blockchain network.
|
||||
* @property depositAddress The on-chain address for deposits.
|
||||
* @property tokenContractAddress The contract address of the token.
|
||||
* @property balance The amount of the crypto balance.
|
||||
*/
|
||||
@Serializable
|
||||
data class CryptoBalance(
|
||||
val id: String,
|
||||
val chainId: Long,
|
||||
val depositAddress: String,
|
||||
val tokenContractAddress: String,
|
||||
val balance: SerializedBigDecimal,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildCryptoCurrencyStatusValue(
|
||||
amount: SerializedBigDecimal,
|
||||
fiatAmount: SerializedBigDecimal,
|
||||
fiatRate: SerializedBigDecimal?,
|
||||
depositAddress: String,
|
||||
): CryptoCurrencyStatus.Value {
|
||||
val networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
value = depositAddress,
|
||||
),
|
||||
)
|
||||
return if (fiatRate != null) {
|
||||
CryptoCurrencyStatus.Loaded(
|
||||
amount = amount,
|
||||
fiatAmount = fiatAmount,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
networkAddress = networkAddress,
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
pendingTransactions = emptySet(),
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
)
|
||||
} else {
|
||||
CryptoCurrencyStatus.NoQuote(
|
||||
amount = amount,
|
||||
networkAddress = networkAddress,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,19 +8,61 @@ import kotlinx.serialization.Serializable
|
|||
* Represents a Tangem Pay card linked to a payment account.
|
||||
*
|
||||
* @property id unique card identifier assigned by the backend.
|
||||
* @property productInstanceId identifier of the owning product instance; used to join a card to its
|
||||
* product instance and to scope card orders.
|
||||
* @property cardStatus backend card status; unknown values map to [Status.UNDEFINED].
|
||||
* @property hasPinCode whether the card has a PIN code set.
|
||||
* @property displayName optional human-readable name assigned to the card; `null` if not set.
|
||||
* @property limit spending limit configuration for the card; `null` if not configured or not yet loaded.
|
||||
* @property isFrozen whether the card is currently frozen (blocked for payments).
|
||||
* @property frozenState whether the card is currently frozen (blocked for payments).
|
||||
* @property lastDigits The last four digits of the card number.
|
||||
* @property state current lifecycle state of the card (reissuing / closing / active).
|
||||
*/
|
||||
@Serializable
|
||||
data class TangemPayCard(
|
||||
@SerialName("id") val id: String,
|
||||
@SerialName("product_instance_id") val productInstanceId: String,
|
||||
@SerialName("card_status") val cardStatus: Status,
|
||||
@SerialName("has_pin_code") val hasPinCode: Boolean,
|
||||
@SerialName("display_name") val displayName: CardDisplayName?,
|
||||
@SerialName("limit") val limit: TangemPayCardLimitData?,
|
||||
@SerialName("is_frozen") val isFrozen: Boolean,
|
||||
@SerialName("frozen_state") val frozenState: TangemPayCardFrozenState,
|
||||
@SerialName("last_digits") val lastDigits: String,
|
||||
@SerialName("is_reissuing") val isReissuing: Boolean,
|
||||
)
|
||||
@SerialName("state") val state: TangemPayCardState,
|
||||
) {
|
||||
|
||||
/** Backend card status — unknown values map to [UNDEFINED] without crashing. */
|
||||
@Serializable
|
||||
enum class Status {
|
||||
@SerialName("ACTIVE")
|
||||
ACTIVE,
|
||||
|
||||
@SerialName("INACTIVE")
|
||||
INACTIVE,
|
||||
|
||||
@SerialName("BLOCKED")
|
||||
BLOCKED,
|
||||
|
||||
@SerialName("CANCELED")
|
||||
CANCELED,
|
||||
|
||||
@SerialName("UNDEFINED")
|
||||
UNDEFINED,
|
||||
;
|
||||
|
||||
val isActive: Boolean get() = this == ACTIVE
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): Status = when (value?.uppercase()) {
|
||||
"ACTIVE" -> ACTIVE
|
||||
"INACTIVE" -> INACTIVE
|
||||
"BLOCKED" -> BLOCKED
|
||||
"CANCELED" -> CANCELED
|
||||
else -> UNDEFINED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val TangemPayCard.isFrozen
|
||||
get() = frozenState == TangemPayCardFrozenState.Frozen
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.tangem.domain.models.pay
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
enum class TangemPayCardFrozenState {
|
||||
@SerialName("Pending")
|
||||
Pending,
|
||||
|
||||
@SerialName("Frozen")
|
||||
Frozen,
|
||||
|
||||
@SerialName("Unfrozen")
|
||||
Unfrozen,
|
||||
;
|
||||
|
||||
override fun toString() = when (this) {
|
||||
Pending -> "Pending"
|
||||
Frozen -> "Frozen"
|
||||
Unfrozen -> "Unfrozen"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String) = when (value.lowercase(Locale.US)) {
|
||||
"frozen" -> Frozen
|
||||
"unfrozen" -> Unfrozen
|
||||
else -> Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.domain.models.pay
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Lifecycle state of a Tangem Pay card.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TangemPayCardState {
|
||||
/** Card is operational and ready to use. */
|
||||
@SerialName("Active")
|
||||
Active,
|
||||
|
||||
/** A reissue order is in progress; the card is being replaced. */
|
||||
@SerialName("Reissuing")
|
||||
Reissuing,
|
||||
|
||||
/** A close order is in progress; the card is being closed. */
|
||||
@SerialName("Closing")
|
||||
Closing,
|
||||
;
|
||||
|
||||
override fun toString() = when (this) {
|
||||
Active -> "Active"
|
||||
Reissuing -> "Reissuing"
|
||||
Closing -> "Closing"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String) = when (value.lowercase(Locale.US)) {
|
||||
"reissuing" -> Reissuing
|
||||
"closing" -> Closing
|
||||
else -> Active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package com.tangem.domain.models.account
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Verifies that [VirtualAccountStatusValue.Active] converts its fiat balance to the app's selected currency
|
||||
* via [VirtualAccountStatusValue.Active.fiatRate] (mirror of the Payment account fix, [REDACTED_TASK_KEY]).
|
||||
*/
|
||||
class VirtualAccountStatusValueTest {
|
||||
|
||||
private val cryptoCurrency = mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
|
||||
private fun activeWith(fiatRate: BigDecimal?, balance: BigDecimal = BigDecimal("100")) =
|
||||
VirtualAccountStatusValue.Active(
|
||||
source = StatusSource.ACTUAL,
|
||||
customerId = "customer",
|
||||
currencyCode = "USD",
|
||||
depositAddress = "0xabc",
|
||||
fiatBalance = VirtualAccountStatusValue.FiatBalance(availableBalance = balance, currency = "USD"),
|
||||
cryptoBalance = VirtualAccountStatusValue.CryptoBalance(
|
||||
id = "usd-coin",
|
||||
chainId = 137L,
|
||||
depositAddress = "0xabc",
|
||||
tokenContractAddress = "0xdef",
|
||||
balance = balance,
|
||||
),
|
||||
availableForWithdrawal = balance,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatRate = fiatRate,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `totalFiatBalance converts balance via fiatRate when rate is present`() {
|
||||
// Arrange
|
||||
val rate = BigDecimal("0.9")
|
||||
val active = activeWith(fiatRate = rate, balance = BigDecimal("100"))
|
||||
|
||||
// Act
|
||||
val result = active.totalFiatBalance
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(result).isInstanceOf(TotalFiatBalance.Loaded::class.java)
|
||||
Truth.assertThat((result as TotalFiatBalance.Loaded).amount)
|
||||
.isEqualTo(BigDecimal("100").multiply(rate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `totalFiatBalance is Failed when fiatRate is null`() {
|
||||
// Arrange
|
||||
val active = activeWith(fiatRate = null)
|
||||
|
||||
// Act & Assert
|
||||
Truth.assertThat(active.totalFiatBalance).isEqualTo(TotalFiatBalance.Failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cryptoCurrencyStatus is NoQuote when fiatRate is null`() {
|
||||
// Arrange
|
||||
val active = activeWith(fiatRate = null)
|
||||
|
||||
// Act & Assert
|
||||
Truth.assertThat(active.cryptoCurrencyStatus.value)
|
||||
.isInstanceOf(CryptoCurrencyStatus.NoQuote::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cryptoCurrencyStatus is Loaded with the rate when fiatRate is present`() {
|
||||
// Arrange
|
||||
val rate = BigDecimal("0.9")
|
||||
val active = activeWith(fiatRate = rate)
|
||||
|
||||
// Act
|
||||
val value = active.cryptoCurrencyStatus.value
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(value).isInstanceOf(CryptoCurrencyStatus.Loaded::class.java)
|
||||
Truth.assertThat((value as CryptoCurrencyStatus.Loaded).fiatRate).isEqualTo(rate)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ dependencies {
|
|||
// end
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import io.mockk.coVerifyOrder
|
|||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class GetApplicationIdUseCaseTest {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import io.mockk.coEvery
|
|||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SendPushTokenUseCaseTest {
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ class SendPushTokenUseCaseTest {
|
|||
private lateinit var pushNotificationsTokenProvider: PushNotificationsTokenProvider
|
||||
private lateinit var sendPushTokenUseCase: SendPushTokenUseCase
|
||||
|
||||
@Before
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
pushNotificationsRepository = mockk()
|
||||
pushNotificationsTokenProvider = mockk()
|
||||
|
|
|
|||
|
|
@ -2,11 +2,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.jvm)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Domain modules */
|
||||
api(projects.domain.core)
|
||||
|
|
@ -14,5 +9,4 @@ dependencies {
|
|||
|
||||
/** Test libraries */
|
||||
testImplementation(projects.test.core)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@ import arrow.core.Either
|
|||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
||||
/**
|
||||
* Use case for getting offramp (sell crypto) URL
|
||||
* Use case for getting offramp (sell crypto) URL.
|
||||
*
|
||||
* Registers a single-use `request_id` in [OfframpRepository] and embeds it into the provider redirect URL so the
|
||||
* returning `redirect_sell` deeplink can be validated as a real, user-initiated sell.
|
||||
*
|
||||
* @property offrampRepository repository for offramp operations
|
||||
*/
|
||||
|
|
@ -15,20 +20,30 @@ class GetOfframpUrlUseCase(
|
|||
private val offrampRepository: OfframpRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrencyCode: String): Either<Error, String> =
|
||||
either {
|
||||
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
|
||||
ensure(walletAddress != null) { Error.WalletAddressNotFound }
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
appCurrencyCode: String,
|
||||
): Either<Error, String> = either<Error, String> {
|
||||
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
|
||||
ensure(walletAddress != null) { Error.WalletAddressNotFound }
|
||||
|
||||
val url = offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrencyStatus.currency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
)
|
||||
ensure(url != null) { Error.UrlNotAvailable }
|
||||
val requestId = offrampRepository.registerPendingOfframp(
|
||||
userWalletId = userWalletId,
|
||||
currencyId = cryptoCurrencyStatus.currency.id.value,
|
||||
)
|
||||
|
||||
url
|
||||
}
|
||||
val url = offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrencyStatus.currency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
ensure(url != null) { Error.UrlNotAvailable }
|
||||
|
||||
url
|
||||
}
|
||||
.onLeft { TangemLogger.e("Error getting offramp URL: $it") }
|
||||
|
||||
/** Offramp use case errors */
|
||||
sealed class Error {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.domain.offramp.model
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* A locally-recorded marker that the app itself initiated a sell (off-ramp) flow.
|
||||
*
|
||||
|
||||
* redirects back via the `redirect_sell` deeplink, the returned `request_id` is matched against a stored
|
||||
* [PendingOfframp] to prove the redirect corresponds to a real, user-initiated sell.
|
||||
*
|
||||
* @property requestId self-issued single-use nonce embedded in the provider redirect URL
|
||||
* @property userWalletId wallet that initiated the sell
|
||||
* @property currencyId [com.tangem.domain.models.currency.CryptoCurrency.ID.value] being sold
|
||||
|
||||
*/
|
||||
data class PendingOfframp(
|
||||
val requestId: String,
|
||||
val userWalletId: UserWalletId,
|
||||
val currencyId: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.tangem.domain.offramp.repository
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.model.PendingOfframp
|
||||
|
||||
/**
|
||||
* Repository for offramp (sell crypto) operations
|
||||
|
|
@ -13,7 +15,31 @@ interface OfframpRepository {
|
|||
* @param cryptoCurrency crypto currency to sell
|
||||
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
|
||||
* @param walletAddress wallet address for the refund
|
||||
* @param requestId single-use nonce embedded into the provider redirect URL to authenticate the
|
||||
* returning `redirect_sell` deeplink
|
||||
* @return URL for offramp service or null if not available
|
||||
*/
|
||||
fun getOfframpUrl(cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String): String?
|
||||
fun getOfframpUrl(
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
fiatCurrencyCode: String,
|
||||
walletAddress: String,
|
||||
requestId: String,
|
||||
): String?
|
||||
|
||||
/**
|
||||
* Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a
|
||||
* fresh single-use `request_id` to embed in the provider redirect URL.
|
||||
*/
|
||||
suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String
|
||||
|
||||
/**
|
||||
* Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was
|
||||
* registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching
|
||||
* record untouched so a tampered redirect cannot burn a legitimate pending sell.
|
||||
*/
|
||||
suspend fun consumePendingOfframp(
|
||||
requestId: String,
|
||||
userWalletId: UserWalletId,
|
||||
currencyId: String,
|
||||
): PendingOfframp?
|
||||
}
|
||||
|
|
@ -4,11 +4,14 @@ import com.google.common.truth.Truth.assertThat
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
|
@ -19,7 +22,12 @@ class GetOfframpUrlUseCaseTest {
|
|||
private val offrampRepository: OfframpRepository = mockk()
|
||||
private val useCase = GetOfframpUrlUseCase(offrampRepository)
|
||||
|
||||
private val cryptoCurrency: CryptoCurrency = mockk()
|
||||
private val userWalletId = UserWalletId("011")
|
||||
private val currencyId = "bitcoin"
|
||||
private val requestId = "request-id-001"
|
||||
private val cryptoCurrency: CryptoCurrency = mockk {
|
||||
every { id } returns mockk { every { value } returns currencyId }
|
||||
}
|
||||
private val appCurrencyCode = "USD"
|
||||
private val walletAddress = "0x1234567890abcdef"
|
||||
private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress"
|
||||
|
|
@ -27,77 +35,82 @@ class GetOfframpUrlUseCaseTest {
|
|||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(offrampRepository)
|
||||
coEvery { offrampRepository.registerPendingOfframp(any(), any()) } returns requestId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke should return url when wallet address and url are available`() {
|
||||
fun `invoke should register request_id and return url when wallet address and url are available`() = runTest {
|
||||
// Arrange
|
||||
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
|
||||
every {
|
||||
coEvery {
|
||||
offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
} returns expectedUrl
|
||||
|
||||
// Act
|
||||
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
|
||||
val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(expectedUrl)
|
||||
|
||||
verify(exactly = 1) {
|
||||
coVerify(exactly = 1) { offrampRepository.registerPendingOfframp(userWalletId, currencyId) }
|
||||
coVerify(exactly = 1) {
|
||||
offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke should return WalletAddressNotFound error when network address is null`() {
|
||||
fun `invoke should return WalletAddressNotFound error when network address is null`() = runTest {
|
||||
// Arrange
|
||||
val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null)
|
||||
|
||||
// Act
|
||||
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
|
||||
val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound)
|
||||
|
||||
verify(exactly = 0) {
|
||||
offrampRepository.getOfframpUrl(any(), any(), any())
|
||||
}
|
||||
coVerify(exactly = 0) { offrampRepository.registerPendingOfframp(any(), any()) }
|
||||
coVerify(exactly = 0) { offrampRepository.getOfframpUrl(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke should return UrlNotAvailable error when repository returns null`() {
|
||||
fun `invoke should return UrlNotAvailable error when repository returns null`() = runTest {
|
||||
// Arrange
|
||||
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
|
||||
every {
|
||||
coEvery {
|
||||
offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
} returns null
|
||||
|
||||
// Act
|
||||
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
|
||||
val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable)
|
||||
|
||||
verify(exactly = 1) {
|
||||
coVerify(exactly = 1) {
|
||||
offrampRepository.getOfframpUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = appCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -123,5 +136,4 @@ class GetOfframpUrlUseCaseTest {
|
|||
every { value } returns statusValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3,11 +3,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.analytics.models)
|
||||
|
|
@ -24,7 +19,6 @@ dependencies {
|
|||
/** Tests */
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.domain.pushnotificationpreferences
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
|
||||
class SetAllWalletPushNotificationPreferencesUseCase(
|
||||
private val repository: WalletPushNotificationPreferencesRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
transactionAlerts: Boolean,
|
||||
offersUpdates: Boolean,
|
||||
priceAlerts: Boolean,
|
||||
): Either<Throwable, Unit> = repository.setAllPreferences(
|
||||
userWalletId = userWalletId,
|
||||
transactionAlerts = transactionAlerts,
|
||||
offersUpdates = offersUpdates,
|
||||
priceAlerts = priceAlerts,
|
||||
)
|
||||
}
|
||||
|
|
@ -4,4 +4,18 @@ data class WalletPushNotificationPreferences(
|
|||
val transactionAlerts: PushNotificationPreference,
|
||||
val offersUpdates: PushNotificationPreference,
|
||||
val priceAlerts: PushNotificationPreference,
|
||||
)
|
||||
) {
|
||||
|
||||
fun withCategory(category: PushNotificationCategory, isEnabled: Boolean): WalletPushNotificationPreferences =
|
||||
when (category) {
|
||||
PushNotificationCategory.TransactionAlerts -> copy(
|
||||
transactionAlerts = transactionAlerts.copy(isEnabled = isEnabled),
|
||||
)
|
||||
PushNotificationCategory.OffersUpdates -> copy(
|
||||
offersUpdates = offersUpdates.copy(isEnabled = isEnabled),
|
||||
)
|
||||
PushNotificationCategory.PriceAlerts -> copy(
|
||||
priceAlerts = priceAlerts.copy(isEnabled = isEnabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,14 @@ interface WalletPushNotificationPreferencesRepository {
|
|||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit>
|
||||
|
||||
/**
|
||||
* Set all categories at once
|
||||
*/
|
||||
suspend fun setAllPreferences(
|
||||
userWalletId: UserWalletId,
|
||||
transactionAlerts: Boolean,
|
||||
offersUpdates: Boolean,
|
||||
priceAlerts: Boolean,
|
||||
): Either<Throwable, Unit>
|
||||
}
|
||||
|
|
@ -8,11 +8,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.staking"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.staking.models)
|
||||
api(projects.domain.core)
|
||||
|
|
@ -35,7 +30,6 @@ dependencies {
|
|||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(tangemDeps.card.core)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ dependencies {
|
|||
implementation(deps.jodatime)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
}
|
||||
|
|
@ -30,4 +30,6 @@ data class SwapCurrencyStatus(
|
|||
get() = status.currency
|
||||
val userWalletId: UserWalletId
|
||||
get() = userWallet.walletId
|
||||
val isYieldSupplyActive: Boolean
|
||||
get() = status.value.yieldSupplyStatus?.isActive == true
|
||||
}
|
||||
|
|
@ -127,12 +127,4 @@ interface SwapRepositoryV2 {
|
|||
txHash: String,
|
||||
txExtraId: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns status [SwapStatusModel] on active swap
|
||||
*
|
||||
* @param userWallet selected user wallet
|
||||
* @param txId transaction id in ExpressApi
|
||||
*/
|
||||
suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): SwapStatusModel
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.tangem.domain.swap.usecase
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class CalculateAmountUseCaseTest {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.tokens"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
|
|
@ -44,6 +39,7 @@ dependencies {
|
|||
implementation(projects.features.staking.api)
|
||||
implementation(projects.features.markets.api)
|
||||
implementation(projects.features.swap.api)
|
||||
implementation(projects.features.virtualAccounts.details.api) //VIRTUAL_ACCOUNTS_ENABLED
|
||||
|
||||
/** Project - Other */
|
||||
implementation(projects.core.configToggles)
|
||||
|
|
@ -62,7 +58,6 @@ dependencies {
|
|||
}
|
||||
|
||||
/** Tests */
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR
|
|||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BALANCE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.CURRENCY
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALUE
|
||||
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
|
||||
|
||||
/**
|
||||
|
|
@ -181,6 +183,21 @@ sealed class TokenScreenAnalyticsEvent(
|
|||
params = mapOf("Token" to token),
|
||||
)
|
||||
|
||||
class ButtonQuickTopUp(
|
||||
token: String,
|
||||
blockchain: String,
|
||||
currency: String,
|
||||
value: String,
|
||||
) : TokenScreenAnalyticsEvent(
|
||||
event = "Quick Top Up Button",
|
||||
params = mapOf(
|
||||
TOKEN_PARAM to token,
|
||||
BLOCKCHAIN to blockchain,
|
||||
CURRENCY to currency,
|
||||
VALUE to value,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val AVAILABLE = "Available"
|
||||
private const val UNAVAILABLE = "Unavailable"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
|||
import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher
|
||||
import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher
|
||||
import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -56,6 +58,8 @@ class WalletBalanceFetcher internal constructor(
|
|||
private val singleWalletBalanceFetcher: BaseWalletBalanceFetcher,
|
||||
private val balanceFetchingOperations: BalanceFetchingOperations,
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
|
||||
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : FlowFetcher<WalletBalanceFetcher.Params> {
|
||||
|
||||
|
|
@ -70,7 +74,9 @@ class WalletBalanceFetcher internal constructor(
|
|||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
|
||||
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
|
||||
stakingIdFactory: StakingIdFactory,
|
||||
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : this(
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
|
|
@ -85,6 +91,8 @@ class WalletBalanceFetcher internal constructor(
|
|||
stakingIdFactory = stakingIdFactory,
|
||||
),
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
|
||||
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
|
|
@ -99,6 +107,8 @@ class WalletBalanceFetcher internal constructor(
|
|||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
|
||||
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
|
||||
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
|
||||
stakingIdFactory: StakingIdFactory,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : this(
|
||||
|
|
@ -121,6 +131,8 @@ class WalletBalanceFetcher internal constructor(
|
|||
stakingIdFactory = stakingIdFactory,
|
||||
),
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
|
||||
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
|
|
@ -177,6 +189,14 @@ class WalletBalanceFetcher internal constructor(
|
|||
balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID))
|
||||
paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId))
|
||||
}
|
||||
|
||||
// Fetch Virtual account separately for the same reason as TangemPay
|
||||
if (
|
||||
fetchingSources.any { it is WalletFetchingSource.VirtualAccount } &&
|
||||
virtualAccountsFeatureToggles.isVirtualAccountsEnabled
|
||||
) {
|
||||
virtualAccountStatusFetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ sealed class WalletFetchingSource {
|
|||
*/
|
||||
data object TangemPay : WalletFetchingSource()
|
||||
|
||||
/**
|
||||
* Virtual account fetching source.
|
||||
* Handled separately from standard balance sources via
|
||||
* [com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher].
|
||||
*/
|
||||
data object VirtualAccount : WalletFetchingSource()
|
||||
|
||||
/**
|
||||
* Standard balance fetching sources (NETWORK, QUOTE, STAKING).
|
||||
* Processed via [BalanceFetchingOperations.fetchAll].
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ internal class MultiWalletBalanceFetcher(
|
|||
sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING),
|
||||
),
|
||||
WalletFetchingSource.TangemPay,
|
||||
WalletFetchingSource.VirtualAccount,
|
||||
)
|
||||
|
||||
override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set<CryptoCurrency> {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import com.tangem.domain.tokens.FetchingSource
|
|||
import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher
|
||||
import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher
|
||||
import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
|
||||
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
|
||||
import com.tangem.test.core.assertEither
|
||||
import com.tangem.test.core.assertEitherRight
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
|
|
@ -50,7 +52,11 @@ internal class WalletBalanceFetcherTest {
|
|||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk()
|
||||
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk()
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
|
||||
private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher = mockk()
|
||||
private val stakingIdFactory: StakingIdFactory = mockk()
|
||||
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles = mockk {
|
||||
every { isVirtualAccountsEnabled } returns false
|
||||
}
|
||||
|
||||
private val fetcher = WalletBalanceFetcher(
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
|
|
@ -62,7 +68,9 @@ internal class WalletBalanceFetcherTest {
|
|||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
multiStakingBalanceFetcher = multiStakingBalanceFetcher,
|
||||
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
|
||||
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
|
||||
stakingIdFactory = stakingIdFactory,
|
||||
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tokens.FetchingSource
|
||||
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import com.tangem.domain.tokens.FetchingSource
|
||||
import com.tangem.domain.tokens.wallet.WalletFetchingSource
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
|
|
@ -54,6 +54,7 @@ class MultiWalletBalanceFetcherTest {
|
|||
sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING),
|
||||
),
|
||||
WalletFetchingSource.TangemPay,
|
||||
WalletFetchingSource.VirtualAccount,
|
||||
)
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ dependencies {
|
|||
implementation(projects.domain.notifications)
|
||||
api(projects.domain.networks)
|
||||
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@
|
|||
<CurrentIssues>
|
||||
<ID>BooleanPropertyNaming:SendTransactionUseCase.kt$SendTransactionUseCase$val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal()</ID>
|
||||
<ID>BooleanPropertyNaming:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$val current = isCurrentAddress(addressToValidate)</ID>
|
||||
<ID>MultilineLambdaItParameter:AssociateAssetUseCase.kt$AssociateAssetUseCase${ val network = currency.network it.network.id == network.id && it.network.derivationPath == network.derivationPath }</ID>
|
||||
<ID>NamedArguments:SendTransactionUseCase.kt$SendTransactionUseCase$invoke(listOf(txData), userWallet, network, TransactionSender.MultipleTransactionSendMode.DEFAULT)</ID>
|
||||
<ID>NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -> currencyAddresses?.any { it.value == toValidate } ?: true }, )</ID>
|
||||
<ID>NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -> senderAddresses.any { it.address == toValidate } }, )</ID>
|
||||
<ID>NullableBooleanCheck:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$currencyAddresses?.any { it.value == toValidate } ?: true</ID>
|
||||
<ID>UnnecessaryLet:RetryIncompleteTransactionUseCase.kt$RetryIncompleteTransactionUseCase$let { raise(IncompleteTransactionError.SendError(it)) }</ID>
|
||||
</CurrentIssues>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>NullableToStringCall:SendTransactionError.kt$SendTransactionError$$code</ID>
|
||||
</CurrentIssues>
|
||||
<CurrentIssues/>
|
||||
</SmellBaseline>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ sealed class GetFeeError {
|
|||
data object TronActivationError : BlockchainErrors()
|
||||
data object KaspaZeroUtxo : BlockchainErrors()
|
||||
data object SuiOneCoinRequired : BlockchainErrors()
|
||||
data object TooLargeSolanaTransactionError : BlockchainErrors()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,4 +20,15 @@ sealed class GetFeeError {
|
|||
data object NotEnoughFunds : GaslessError()
|
||||
data class DataError(val cause: Throwable?) : GaslessError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for gas estimation with state override for ethereum like networks.
|
||||
* Specifically overriding approval slot.
|
||||
*/
|
||||
data class EstimateOverrideError(
|
||||
val blockchain: String,
|
||||
val tokenSymbol: String,
|
||||
val rpcProvider: String,
|
||||
val error: String,
|
||||
) : GetFeeError()
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_C
|
|||
import com.tangem.sdk.extensions.localizedDescriptionRes
|
||||
|
||||
fun Result.Failure.mapToFeeError(): GetFeeError {
|
||||
return when (this.error) {
|
||||
return when (val gasError = error) {
|
||||
is BlockchainSdkError.Tron.AccountActivationError -> {
|
||||
GetFeeError.BlockchainErrors.TronActivationError
|
||||
}
|
||||
|
|
@ -19,7 +19,15 @@ fun Result.Failure.mapToFeeError(): GetFeeError {
|
|||
is BlockchainSdkError.Sui.OneSuiRequired -> {
|
||||
GetFeeError.BlockchainErrors.SuiOneCoinRequired
|
||||
}
|
||||
else -> GetFeeError.DataError(this.error)
|
||||
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
|
||||
GetFeeError.EstimateOverrideError(
|
||||
blockchain = gasError.blockchain,
|
||||
tokenSymbol = gasError.tokenSymbol,
|
||||
rpcProvider = gasError.rpcProvider,
|
||||
error = gasError.underlyingError,
|
||||
)
|
||||
}
|
||||
else -> GetFeeError.DataError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val minimalFee = getEthLegacyFee(
|
||||
gasPrice = gasPriceResult,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val normalFee = getEthLegacyFee(
|
||||
gasPrice = normalGasPrice,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase(
|
|||
val priorityFee = getEthLegacyFee(
|
||||
gasPrice = priorityGasPrice,
|
||||
gasLimit = gasLimit,
|
||||
decimals = cryptoCurrency.decimals,
|
||||
decimals = blockchain.decimals(),
|
||||
blockchain = blockchain,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.transaction.usecase
|
|||
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
|
|
@ -35,7 +36,13 @@ class GetFeeUseCase(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val demoConfig: DemoConfig,
|
||||
) {
|
||||
suspend operator fun invoke(userWallet: UserWallet, network: Network, transactionData: TransactionData) = either {
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
transactionData: TransactionData,
|
||||
spenderAddress: String? = null,
|
||||
isSimulateEstimation: Boolean = false,
|
||||
) = either {
|
||||
catch(
|
||||
block = {
|
||||
val transactionSender = if (userWallet is UserWallet.Cold &&
|
||||
|
|
@ -48,8 +55,17 @@ class GetFeeUseCase(
|
|||
network = network,
|
||||
)
|
||||
}
|
||||
val result = transactionSender?.getFee(transactionData = transactionData)
|
||||
?: error("Fee is null")
|
||||
val isEthereumWalletManager = transactionSender is EthereumWalletManager
|
||||
val result = if (isSimulateEstimation && spenderAddress != null && isEthereumWalletManager) {
|
||||
transactionSender.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = spenderAddress,
|
||||
isSimulate = true,
|
||||
)
|
||||
} else {
|
||||
transactionSender?.getFee(transactionData = transactionData)
|
||||
?: error("Fee is null")
|
||||
}
|
||||
|
||||
val maybeFee = when (result) {
|
||||
is Result.Success -> result.data
|
||||
|
|
|
|||
|
|
@ -199,7 +199,6 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
|
||||
transactionData = transactionData,
|
||||
txHash = txHash,
|
||||
contractAddress = transactionData.contractAddress,
|
||||
)
|
||||
|
||||
return txHash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.tangem.domain.transaction.error
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.BlockchainSdkError
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for [mapToFeeError] — the [Result.Failure] -> [GetFeeError] mapper. Focuses on the
|
||||
* [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be mapped to
|
||||
* [GetFeeError.EstimateOverrideError] field-by-field; all other errors fall through to
|
||||
* [GetFeeError.DataError].
|
||||
*/
|
||||
internal class ErrorsMapperTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError THEN maps to GetFeeError EstimateOverrideError field by field`() {
|
||||
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
underlyingError = "execution reverted",
|
||||
)
|
||||
|
||||
val result = Result.Failure(sdkError).mapToFeeError()
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
|
||||
val mapped = result as GetFeeError.EstimateOverrideError
|
||||
assertThat(mapped.blockchain).isEqualTo("ethereum")
|
||||
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
|
||||
assertThat(mapped.rpcProvider).isEqualTo("infura")
|
||||
assertThat(mapped.error).isEqualTo("execution reverted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TronActivationError THEN maps to TronActivationError`() {
|
||||
// AccountActivationError is a class taking an int code, not an object.
|
||||
val result = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 0)).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN KaspaZeroUtxoError THEN maps to KaspaZeroUtxo`() {
|
||||
val result = Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN SuiOneSuiRequired THEN maps to SuiOneCoinRequired`() {
|
||||
val result = Result.Failure(BlockchainSdkError.Sui.OneSuiRequired).mapToFeeError()
|
||||
|
||||
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown error THEN maps to DataError`() {
|
||||
val sdkError = BlockchainSdkError.CustomError("boom")
|
||||
|
||||
val result = Result.Failure(sdkError).mapToFeeError()
|
||||
|
||||
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
package com.tangem.domain.transaction.usecase
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
|
|
@ -17,48 +19,185 @@ import com.tangem.domain.transaction.error.GetFeeError
|
|||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [GetFeeUseCase].
|
||||
*
|
||||
* Focus is the Yield Mode gas-limit logic introduced on this branch
|
||||
* (uncompiled Ethereum transactions whose call data is [EthereumYieldSupplySendCallData]
|
||||
* get their gas limit increased by 40%), plus error mapping, null/exception handling,
|
||||
* demo card routing, and crypto-currency-to-amount conversion in the second overload.
|
||||
* Covers two orthogonal pieces of the compiled-transaction overload:
|
||||
* - The fee-source selection that chooses between the simulated `estimateFeeWithOverride` path and the legacy
|
||||
* `getFee` path. The simulated estimation is selected only when ALL of these hold:
|
||||
* - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true`
|
||||
* - `spenderAddress != null`
|
||||
* - the resolved transaction sender is an [EthereumWalletManager]
|
||||
* - The Yield Mode gas-limit logic: uncompiled Ethereum transactions whose call data is
|
||||
* [EthereumYieldSupplySendCallData] get their gas limit increased by 40%.
|
||||
*
|
||||
* Plus error mapping, null/exception handling, demo card routing, and crypto-currency-to-amount conversion in the
|
||||
* second overload.
|
||||
*/
|
||||
class GetFeeUseCaseTest {
|
||||
internal class GetFeeUseCaseTest {
|
||||
|
||||
private lateinit var walletManagersFacade: WalletManagersFacade
|
||||
private lateinit var demoConfig: DemoConfig
|
||||
private lateinit var useCase: GetFeeUseCase
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val demoConfig: DemoConfig = mockk()
|
||||
|
||||
private lateinit var walletManager: WalletManager
|
||||
private lateinit var network: Network
|
||||
private lateinit var userWallet: UserWallet.Hot
|
||||
private lateinit var userWalletId: UserWalletId
|
||||
private val useCase = GetFeeUseCase(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
demoConfig = demoConfig,
|
||||
)
|
||||
|
||||
@Before
|
||||
private val network: Network = mockk(relaxed = true)
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val userWallet: UserWallet.Hot = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val walletManager: WalletManager = mockk()
|
||||
private val ethereumWalletManager: EthereumWalletManager = mockk()
|
||||
private val plainWalletManager: WalletManager = mockk()
|
||||
|
||||
private val transactionData: TransactionData = mockk(relaxed = true)
|
||||
private val expectedFee: TransactionFee = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
walletManagersFacade = mockk()
|
||||
demoConfig = mockk()
|
||||
useCase = GetFeeUseCase(walletManagersFacade, demoConfig)
|
||||
|
||||
walletManager = mockk()
|
||||
network = mockk()
|
||||
userWalletId = mockk()
|
||||
userWallet = mockk<UserWallet.Hot>()
|
||||
|
||||
every { demoConfig.isDemoCardId(any()) } returns false
|
||||
every { userWallet.walletId } returns userWalletId
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns walletManager
|
||||
}
|
||||
|
||||
// region invoke(userWallet, network, transactionData, spenderAddress, isSimulateEstimation) — fee-source selection
|
||||
|
||||
@Test
|
||||
fun `GIVEN simulate + spender + ethereum manager THEN estimateFee is used`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulate = true,
|
||||
)
|
||||
} returns Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulate = true,
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN simulate false THEN legacy getFee is used even for ethereum manager`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = false,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
coVerify(exactly = 0) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulate = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null spender THEN legacy getFee is used even when simulate true`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns ethereumWalletManager
|
||||
coEvery { ethereumWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = null,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { ethereumWalletManager.getFee(transactionData = transactionData) }
|
||||
coVerify(exactly = 0) {
|
||||
ethereumWalletManager.estimateFeeWithOverride(
|
||||
transactionData = any(),
|
||||
spenderAddress = any(),
|
||||
isSimulate = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-ethereum manager THEN legacy getFee is used even when simulate plus spender`() = runTest {
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns plainWalletManager
|
||||
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns
|
||||
Result.Success(expectedFee)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
spenderAddress = SPENDER,
|
||||
isSimulateEstimation = true,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(expectedFee.right())
|
||||
coVerify(exactly = 1) { plainWalletManager.getFee(transactionData = transactionData) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN getFee returns failure THEN error is mapped to GetFeeError`() = runTest {
|
||||
val failure = Result.Failure(BlockchainSdkError.CustomError("boom"))
|
||||
coEvery {
|
||||
walletManagersFacade.getOrCreateWalletManager(userWalletId, network)
|
||||
} returns plainWalletManager
|
||||
coEvery { plainWalletManager.getFee(transactionData = transactionData) } returns failure
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
network = network,
|
||||
transactionData = transactionData,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region invoke(userWallet, network, transactionData) — Yield Mode gas-limit logic
|
||||
|
||||
@Test
|
||||
|
|
@ -475,4 +614,8 @@ class GetFeeUseCaseTest {
|
|||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,9 @@ import io.mockk.mockk
|
|||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class ValidateWalletAddressUseCaseTest {
|
||||
|
||||
|
|
@ -34,14 +34,14 @@ internal class ValidateWalletAddressUseCaseTest {
|
|||
private val userWalletId: UserWalletId = mockk()
|
||||
private val network: Network = mockk()
|
||||
|
||||
@Before
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(BlockchainUtils)
|
||||
every { BlockchainUtils.decodeRippleXAddress(any(), any()) } returns null
|
||||
every { network.rawId } returns "ethereum"
|
||||
}
|
||||
|
||||
@After
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(BlockchainUtils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ import io.mockk.coVerify
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ class TokenFeeCalculatorTest {
|
|||
private lateinit var mockUserWalletId: UserWalletId
|
||||
private lateinit var mockTransactionData: TransactionData
|
||||
|
||||
@Before
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
walletManagersFacade = mockk()
|
||||
gaslessTransactionRepository = mockk()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.domain.txhistory.fetcher
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
sealed interface TxHistoryFetchTrigger {
|
||||
|
||||
data class TokenDetailsOpen(
|
||||
val walletId: UserWalletId,
|
||||
val currency: CryptoCurrency,
|
||||
) : TxHistoryFetchTrigger, TxHistoryExpressTrigger, TxHistoryGatewayTrigger
|
||||
|
||||
data class TokenDetailsPTR(
|
||||
val walletId: UserWalletId,
|
||||
val currency: CryptoCurrency,
|
||||
) : TxHistoryFetchTrigger, TxHistoryExpressTrigger, TxHistoryGatewayTrigger
|
||||
}
|
||||
|
||||
sealed interface TxHistoryExpressTrigger : TxHistoryFetchTrigger
|
||||
sealed interface TxHistoryGatewayTrigger : TxHistoryFetchTrigger
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.domain.txhistory.fetcher
|
||||
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
interface TxHistoryFetcher<T : TxHistoryFetchTrigger> {
|
||||
suspend fun invoke(params: T)
|
||||
fun close()
|
||||
}
|
||||
|
||||
interface AppTxHistoryFetcher : TxHistoryFetcher<TxHistoryFetchTrigger>
|
||||
|
||||
interface WalletTxHistoryFetcher : TxHistoryFetcher<TxHistoryFetchTrigger> {
|
||||
val walletId: UserWalletId
|
||||
}
|
||||
|
||||
interface AccountTxHistoryFetcher : TxHistoryFetcher<TxHistoryFetchTrigger> {
|
||||
val accountId: AccountId
|
||||
val walletId: UserWalletId get() = accountId.userWalletId
|
||||
}
|
||||
|
||||
interface ExpressTxHistoryFetcher : TxHistoryFetcher<TxHistoryExpressTrigger> {
|
||||
val address: String
|
||||
}
|
||||
|
|
@ -8,11 +8,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.domain.visa"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Project - Core */
|
||||
api(projects.core.pagination)
|
||||
|
|
@ -42,9 +37,8 @@ dependencies {
|
|||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ sealed class VisaApiError(
|
|||
data object ProductInstanceIsNotActivated : VisaApiError(104110208)
|
||||
data object ProductInstanceIsAlreadyActivated : VisaApiError(104110207)
|
||||
data object CustomerIsBlocked : VisaApiError(104110210)
|
||||
data object CardIssueInsufficientBalance : VisaApiError(104140116)
|
||||
data object UnknownWithoutCode : VisaApiError(104110999)
|
||||
data class Unknown(override val errorCode: Int) : VisaApiError(errorCode)
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ sealed class VisaApiError(
|
|||
ProductInstanceIsNotActivated.errorCode -> ProductInstanceIsNotActivated
|
||||
ProductInstanceIsAlreadyActivated.errorCode -> ProductInstanceIsAlreadyActivated
|
||||
CustomerIsBlocked.errorCode -> CustomerIsBlocked
|
||||
CardIssueInsufficientBalance.errorCode -> CardIssueInsufficientBalance
|
||||
else -> Unknown(universalErrorCode)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
package com.tangem.domain.visa.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class TangemPayCardFrozenState {
|
||||
data object Pending : TangemPayCardFrozenState()
|
||||
data object Frozen : TangemPayCardFrozenState()
|
||||
data object Unfrozen : TangemPayCardFrozenState()
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@ package com.tangem.domain.pay.model
|
|||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import com.tangem.domain.visa.model.TangemPayCardFrozenState
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -22,13 +23,21 @@ data class MainScreenCustomerInfo(
|
|||
|
||||
data class CustomerInfo(
|
||||
val customerId: String?,
|
||||
val productInstance: ProductInstance?,
|
||||
val productInstances: List<ProductInstance>,
|
||||
val cards: List<CardInfo>,
|
||||
val kycStatus: KycStatus,
|
||||
val cardInfo: CardInfo?,
|
||||
val state: State,
|
||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
|
||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
|
||||
val availableForWithdrawal: BigDecimal,
|
||||
) {
|
||||
|
||||
/** Transitional single-card accessor — returns the first product instance, or null if none. */
|
||||
val productInstance: ProductInstance? get() = productInstances.firstOrNull()
|
||||
|
||||
/** Transitional single-card accessor — returns the first card, or null if none. */
|
||||
val cardInfo: CardInfo? get() = cards.firstOrNull()
|
||||
|
||||
enum class State {
|
||||
NEW,
|
||||
ACTIVE,
|
||||
|
|
@ -76,13 +85,10 @@ data class CustomerInfo(
|
|||
}
|
||||
|
||||
data class CardInfo(
|
||||
/** Card identifier — matches [ProductInstance.cardId] to join a card to its product instance. */
|
||||
val cardId: String,
|
||||
val cardStatus: TangemPayCard.Status,
|
||||
val lastFourDigits: String,
|
||||
val balance: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val depositAddress: String?,
|
||||
val isPinSet: Boolean,
|
||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance,
|
||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance,
|
||||
val availableForWithdrawal: BigDecimal,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Customer offer returned by `GET /v1/customer/offers`.
|
||||
*
|
||||
* Used to gate the issue-additional-card flow: the offer fee drives the popup amount, and the
|
||||
* presence of the offer enables the "+" action.
|
||||
*/
|
||||
data class Offer(
|
||||
val type: Type,
|
||||
val fee: Fee,
|
||||
val data: Data,
|
||||
) {
|
||||
|
||||
data class Data(val specificationName: String, val orderType: OrderType)
|
||||
|
||||
/** Offer type — unknown wire values resolve to [UNKNOWN]. */
|
||||
enum class Type(val wireValue: String) {
|
||||
CARD_ISSUE_VIRTUAL_RAIN("CARD_ISSUE_VIRTUAL_RAIN"),
|
||||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): Type {
|
||||
if (value.isNullOrBlank()) return UNKNOWN
|
||||
return entries.firstOrNull { it.wireValue == value || it.name == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Fee(
|
||||
val amount: BigDecimal,
|
||||
val currency: Currency,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
/**
|
||||
* Domain model for a TangemPay order returned by `GET /v1/order` (findOrders) or `GET /v1/order/{id}`.
|
||||
*
|
||||
* Each order carries enough context to be matched to the originating card / product instance
|
||||
* for card-scoped flows.
|
||||
*
|
||||
* @property id backend order identifier.
|
||||
* @property type order type; unknown values resolve to [OrderType.UNKNOWN].
|
||||
* @property status current order status.
|
||||
* @property step optional per-status step indicator (KYC / Rain / Issue / Fee / Activation / …).
|
||||
* @property stepChangeCode optional code accompanying step transitions.
|
||||
* @property productInstanceId set for card-scoped orders.
|
||||
* @property paymentAccountId set for card-scoped and payment-account-level orders.
|
||||
* @property cardId set for card-scoped orders that are filtered by card.
|
||||
* @property withdrawTxHash present for completed [OrderType.WITHDRAW] orders.
|
||||
* @property updatedAt ISO-8601 timestamp used to pick the most recent matching order.
|
||||
*/
|
||||
data class Order(
|
||||
val id: String,
|
||||
val customerId: String?,
|
||||
val type: OrderType,
|
||||
val status: OrderStatus,
|
||||
val step: String?,
|
||||
val stepChangeCode: Int?,
|
||||
val productInstanceId: String?,
|
||||
val paymentAccountId: String?,
|
||||
val cardId: String?,
|
||||
val withdrawTxHash: String?,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?,
|
||||
) {
|
||||
/** True when the order belongs to a specific card/product instance (vs payment-account-level). */
|
||||
val isCardScoped: Boolean get() = productInstanceId != null
|
||||
|
||||
/** True when the order is still in flight. */
|
||||
val isActive: Boolean get() = status.isActive
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
/**
|
||||
* Decides whether a requested user action is allowed given the set of currently active orders
|
||||
* (an order is active while its status is NEW or PROCESSING).
|
||||
*
|
||||
* Rules:
|
||||
* - Issue (any card) — blocks Issue; does not block withdraw / freeze-unfreeze / rename of others.
|
||||
* - Freeze A — blocks Freeze A and Unfreeze A.
|
||||
* - Unfreeze A — symmetric to Freeze A.
|
||||
* - Withdraw — blocks Withdraw; does not block freeze-unfreeze / rename.
|
||||
* - Reissue A — blocks Freeze A / Unfreeze A / Reissue A.
|
||||
* - Rename — never blocked.
|
||||
*/
|
||||
sealed interface ConflictResolution {
|
||||
data object Allowed : ConflictResolution
|
||||
|
||||
/**
|
||||
* @property blockingOrder the active order that blocks the requested intent — useful for
|
||||
* routing the user to the in-flight progress screen instead of a flat error.
|
||||
*/
|
||||
data class Blocked(val blockingOrder: Order) : ConflictResolution
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct user-driven intents that may conflict with active orders.
|
||||
*
|
||||
* Card-scoped intents carry `productInstanceId`: orders are matched by product instance because the
|
||||
* v1 order response carries `productInstanceId` but no card id (see [Order.cardId]). The caller has
|
||||
* the product instance via `TangemPayCard.productInstanceId`.
|
||||
*/
|
||||
sealed interface OrderIntent {
|
||||
data object IssueCard : OrderIntent
|
||||
data class Freeze(val productInstanceId: String) : OrderIntent
|
||||
data class Unfreeze(val productInstanceId: String) : OrderIntent
|
||||
data class Reissue(val productInstanceId: String) : OrderIntent
|
||||
data object Withdraw : OrderIntent
|
||||
data class Rename(val productInstanceId: String) : OrderIntent
|
||||
}
|
||||
|
||||
/** Stateless evaluator of the order-conflict rules. */
|
||||
object OrderConflictRules {
|
||||
|
||||
fun resolve(intent: OrderIntent, activeOrders: List<Order>): ConflictResolution {
|
||||
val blockingOrder = activeOrders.firstOrNull { order -> blocks(intent, order) }
|
||||
return if (blockingOrder == null) ConflictResolution.Allowed else ConflictResolution.Blocked(blockingOrder)
|
||||
}
|
||||
|
||||
private fun blocks(intent: OrderIntent, order: Order): Boolean {
|
||||
if (!order.isActive) return false
|
||||
return when (intent) {
|
||||
OrderIntent.IssueCard -> order.type.isIssuing()
|
||||
OrderIntent.Withdraw -> order.type == OrderType.WITHDRAW
|
||||
is OrderIntent.Freeze -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Unfreeze -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Reissue -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
is OrderIntent.Rename -> false // Rename is never blocked.
|
||||
}
|
||||
}
|
||||
|
||||
private fun sameProductInstance(order: Order, productInstanceId: String): Boolean {
|
||||
return order.productInstanceId == productInstanceId
|
||||
}
|
||||
|
||||
private fun OrderType.isIssuing(): Boolean {
|
||||
return this == OrderType.CARD_ISSUE ||
|
||||
this == OrderType.CARD_ISSUE_ADDITIONAL ||
|
||||
this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2
|
||||
}
|
||||
|
||||
private fun OrderType.isFreezeOrReissue(): Boolean {
|
||||
return this == OrderType.CARD_FREEZE ||
|
||||
this == OrderType.CARD_UNFREEZE ||
|
||||
this == OrderType.CARD_REISSUE
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,11 @@ enum class OrderStatus {
|
|||
PROCESSING,
|
||||
COMPLETED,
|
||||
CANCELED,
|
||||
}
|
||||
;
|
||||
|
||||
val OrderStatus.isFinalStatus
|
||||
get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED
|
||||
/** An order is active while it is still being processed (NEW or PROCESSING). */
|
||||
val isActive: Boolean get() = this == NEW || this == PROCESSING
|
||||
|
||||
/** Terminal statuses (COMPLETED or CANCELED) — used to invalidate the local order hint. */
|
||||
val isTerminal: Boolean get() = this == COMPLETED || this == CANCELED
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import com.tangem.domain.pay.model.OrderType.Companion.fromString
|
||||
|
||||
/**
|
||||
* Order type used for findOrders filtering and order-conflict checks.
|
||||
*
|
||||
* Backend wire values are mapped via [fromString]; unknown values resolve to [UNKNOWN]
|
||||
* so the app never crashes on a new server-side type.
|
||||
*/
|
||||
enum class OrderType(val wireValue: String) {
|
||||
CARD_ISSUE("CARD_ISSUE_VIRTUAL_RAIN_KYC"),
|
||||
CARD_ISSUE_ADDITIONAL("CARD_ISSUE_ADDITIONAL"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"),
|
||||
CARD_REISSUE("CARD_REISSUE"),
|
||||
CARD_FREEZE("CARD_FREEZE"),
|
||||
CARD_UNFREEZE("CARD_UNFREEZE"),
|
||||
WITHDRAW("WITHDRAW"),
|
||||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): OrderType {
|
||||
if (value.isNullOrBlank()) return UNKNOWN
|
||||
return entries.firstOrNull { it.wireValue == value || it.name == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.domain.pay.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
* Repository for `GET /v1/customer/offers`.
|
||||
*
|
||||
* Used by the issue-additional-card flow to:
|
||||
* - check whether the additional-card offer is available;
|
||||
* - drive the popup amount via [Offer.fee].
|
||||
*/
|
||||
interface CustomerOffersRepository {
|
||||
|
||||
suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue