Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 &amp;&amp; currency.network.derivationPath.value == derivationPath.value &amp;&amp; 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>

View file

@ -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,

View 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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]

View file

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

View file

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

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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"

View file

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

View file

@ -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].

View file

@ -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> {

View file

@ -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(),
)

View file

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

View file

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

View file

@ -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 &amp;&amp; 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 -&gt; currencyAddresses?.any { it.value == toValidate } ?: true }, )</ID>
<ID>NamedArguments:ValidateWalletAddressUseCase.kt$ValidateWalletAddressUseCase$validateAddressInternal( userWalletId, network, address, isCurrentAddress = { toValidate -&gt; 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>

View file

@ -1,7 +1,5 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NullableToStringCall:SendTransactionError.kt$SendTransactionError$$code</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View file

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

View file

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

View file

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

View file

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

View file

@ -199,7 +199,6 @@ class CreateAndSendGaslessTransactionUseCase(
(context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction(
transactionData = transactionData,
txHash = txHash,
contractAddress = transactionData.contractAddress,
)
return txHash

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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