Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-20 11:21:17 +02:00
parent d128161e53
commit 4c80e2eaa2
16 changed files with 678 additions and 192 deletions

View file

@ -18,26 +18,6 @@ enum class PaymentMethodType(val id: String?) {
OTHER(id = null),
;
// TODO will be removed in next request [REDACTED_TASK_KEY]
@Suppress("MagicNumber")
fun getPriority(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) {
when (this) {
GOOGLE_PAY -> 0
CARD -> 1
REVOLUT_PAY -> 2
SEPA -> 3
OTHER -> 4
}
} else {
when (this) {
CARD -> 2
REVOLUT_PAY -> 1
SEPA -> 2
OTHER -> 3
GOOGLE_PAY -> 4
}
}
/**
* Get priority regardless of real speed. By business logic.
*/

View file

@ -8,12 +8,10 @@ data class OnrampPaymentMethodGroup(
val bestRateOffer: OnrampOffer?,
val providerCount: Int,
val isBestPaymentMethod: Boolean,
) {
val methodStatus: PaymentMethodStatus,
)
val bestRateAmount: BigDecimal? = bestRateOffer?.let { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote.toAmount.value
else -> BigDecimal.ZERO
}
}
sealed interface PaymentMethodStatus {
data object Available : PaymentMethodStatus
data class Unavailable(val availableFrom: BigDecimal) : PaymentMethodStatus
}

View file

@ -5,10 +5,7 @@ import arrow.core.right
import com.tangem.domain.core.utils.EitherFlow
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampOffer
import com.tangem.domain.onramp.model.OnrampOfferAdvantages
import com.tangem.domain.onramp.model.OnrampPaymentMethodGroup
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
@ -35,17 +32,73 @@ class GetOnrampAllOffersUseCase(
}
private suspend fun processAllOffers(quotes: List<OnrampQuote>): List<OnrampPaymentMethodGroup> {
val validQuotes = quotes.filterIsInstance<OnrampQuote.Data>()
if (validQuotes.isEmpty()) return emptyList()
val relevantQuotes = quotes.filter { it !is OnrampQuote.Error }
if (relevantQuotes.isEmpty()) return emptyList()
val isGooglePayAvailable = settingsRepository.isGooglePayAvailability()
val overallBestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable))
val validQuotes = relevantQuotes.filterIsInstance<OnrampQuote.Data>()
val overallBestRateQuote = findOverallBestRateQuote(validQuotes, isGooglePayAvailable)
val bestRate = overallBestRateQuote?.toAmount?.value
val offersByPaymentMethod = validQuotes.groupBy { it.paymentMethod }
val offersByPaymentMethod = relevantQuotes.groupBy { it.paymentMethod }
return offersByPaymentMethod.map { (paymentMethod, methodQuotes) ->
val methodOffers = methodQuotes.map { quote ->
return offersByPaymentMethod
.map { (paymentMethod, methodQuotes) ->
createPaymentMethodGroup(
paymentMethod = paymentMethod,
methodQuotes = methodQuotes,
overallBestRateQuote = overallBestRateQuote,
bestRate = bestRate,
isGooglePayAvailable = isGooglePayAvailable,
)
}
.sortedBy { it.paymentMethod.type.getPriorityForMethod(isGooglePayAvailable) }
}
private fun findOverallBestRateQuote(
validQuotes: List<OnrampQuote.Data>,
isGooglePayAvailable: Boolean,
): OnrampQuote.Data? {
return validQuotes.maxWithOrNull(
compareOffersByRateSpeedAndPriority(
isGooglePayAvailable = isGooglePayAvailable,
isSepaPrioritized = false,
),
)
}
private fun createPaymentMethodGroup(
paymentMethod: OnrampPaymentMethod,
methodQuotes: List<OnrampQuote>,
overallBestRateQuote: OnrampQuote.Data?,
bestRate: BigDecimal?,
isGooglePayAvailable: Boolean,
): OnrampPaymentMethodGroup {
val methodOffers = methodQuotes.mapNotNull { quote ->
createOffer(quote, overallBestRateQuote, bestRate)
}
val groupBestRateQuote = findGroupBestRateQuote(methodQuotes, isGooglePayAvailable)
val groupBestRateOffer = findBestRateOffer(methodOffers, groupBestRateQuote)
return OnrampPaymentMethodGroup(
paymentMethod = paymentMethod,
offers = sortOffers(methodOffers),
providerCount = countUniqueProviders(methodOffers),
bestRateOffer = groupBestRateOffer,
isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod,
methodStatus = determineMethodStatus(methodQuotes),
)
}
private fun createOffer(
quote: OnrampQuote,
overallBestRateQuote: OnrampQuote.Data?,
bestRate: BigDecimal?,
): OnrampOffer? {
return when (quote) {
is OnrampQuote.Data -> {
val advantages = if (quote == overallBestRateQuote) {
OnrampOfferAdvantages.BestRate
} else {
@ -54,28 +107,70 @@ class GetOnrampAllOffersUseCase(
val rateDif = calculateRateDif(quote.toAmount.value, bestRate)
OnrampOffer(quote = quote, rateDif = rateDif, advantages = advantages)
}
val groupBestRateOfferData =
methodQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable))
val groupBestRateOffer = methodOffers.find {
when (val quote = it.quote) {
is OnrampQuote.Data -> quote == groupBestRateOfferData
else -> false
}
is OnrampQuote.AmountError -> {
OnrampOffer(quote = quote, rateDif = null, advantages = OnrampOfferAdvantages.Default)
}
is OnrampQuote.Error -> null
}
}
OnrampPaymentMethodGroup(
paymentMethod = paymentMethod,
offers = methodOffers.sortedByDescending { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote.toAmount.value
else -> BigDecimal.ZERO
}
},
providerCount = methodOffers.map { it.quote.provider.id }.distinct().size,
bestRateOffer = groupBestRateOffer,
isBestPaymentMethod = overallBestRateQuote?.paymentMethod == paymentMethod,
)
}.sortedBy { it.paymentMethod.type.getPriority(isGooglePayAvailable) }
private fun findGroupBestRateQuote(
validMethodQuotes: List<OnrampQuote>,
isGooglePayAvailable: Boolean,
): OnrampQuote? {
val dataQuotes = validMethodQuotes.filterIsInstance<OnrampQuote.Data>()
val amountErrorQuotes = validMethodQuotes.filterIsInstance<OnrampQuote.AmountError>()
return when {
dataQuotes.isNotEmpty() -> {
dataQuotes.maxWithOrNull(
compareOffersByRateSpeedAndPriority(
isGooglePayAvailable = isGooglePayAvailable,
isSepaPrioritized = false,
),
)
}
amountErrorQuotes.isNotEmpty() -> {
amountErrorQuotes.minByOrNull { it.error.requiredAmount }
}
else -> validMethodQuotes.firstOrNull()
}
}
private fun findBestRateOffer(methodOffers: List<OnrampOffer>, groupBestRateQuote: OnrampQuote?): OnrampOffer? {
if (groupBestRateQuote == null) return null
return methodOffers.find { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote == groupBestRateQuote
is OnrampQuote.AmountError -> quote == groupBestRateQuote
is OnrampQuote.Error -> false
}
}
}
private fun sortOffers(methodOffers: List<OnrampOffer>): List<OnrampOffer> {
return methodOffers.sortedByDescending { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote.toAmount.value
else -> BigDecimal.ZERO
}
}
}
private fun countUniqueProviders(methodOffers: List<OnrampOffer>): Int {
return methodOffers.map { it.quote.provider.id }.distinct().size
}
private fun determineMethodStatus(methodQuotes: List<OnrampQuote>): PaymentMethodStatus {
val hasAtLeastOneValidQuote = methodQuotes.any { it is OnrampQuote.Data }
return if (hasAtLeastOneValidQuote) {
PaymentMethodStatus.Available
} else {
val minSum = methodQuotes
.filterIsInstance<OnrampQuote.AmountError>()
.minOfOrNull { it.error.requiredAmount } ?: BigDecimal.ZERO
PaymentMethodStatus.Unavailable(minSum)
}
}
}

View file

@ -77,8 +77,7 @@ class GetOnrampOffersUseCase(
return offers.find { offer ->
offer.quote.provider.info.name == lastTransaction.providerName &&
offer.quote.paymentMethod.name == lastTransaction.paymentMethod &&
lastTransaction.status.isTerminal
offer.quote.paymentMethod.name == lastTransaction.paymentMethod && isRecentUsed(lastTransaction.status)
}
}
@ -249,4 +248,23 @@ class GetOnrampOffersUseCase(
return offer1.quote.provider.id == offer2.quote.provider.id &&
offer1.quote.paymentMethod.id == offer2.quote.paymentMethod.id
}
private fun isRecentUsed(onrampStatus: OnrampStatus.Status): Boolean {
return when (onrampStatus) {
OnrampStatus.Status.Created,
OnrampStatus.Status.Expired,
OnrampStatus.Status.Paused,
OnrampStatus.Status.WaitingForPayment,
OnrampStatus.Status.PaymentProcessing,
OnrampStatus.Status.Verifying,
OnrampStatus.Status.Paid,
OnrampStatus.Status.Sending,
OnrampStatus.Status.RefundInProgress,
-> false
OnrampStatus.Status.Failed,
OnrampStatus.Status.Finished,
OnrampStatus.Status.Refunded,
-> true
}
}
}

View file

@ -3,10 +3,14 @@ package com.tangem.domain.onramp
import com.google.common.truth.Truth
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampAmount
import com.tangem.domain.onramp.model.OnrampOfferAdvantages
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampProvider
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.settings.repositories.SettingsRepository
@ -40,7 +44,7 @@ class GetOnrampAllOffersUseCaseTest {
}
@Test
fun `invoke should return empty list when no valid quotes`() = runTest {
fun `should return empty list when no quotes`() = runTest {
val emptyQuotes = listOf<OnrampQuote>()
coEvery { onrampRepository.getQuotes() } returns flowOf(emptyQuotes)
@ -57,16 +61,40 @@ class GetOnrampAllOffersUseCaseTest {
}
@Test
fun `invoke should return grouped offers with best rate marked`() = runTest {
val paymentMethod1 = createMockPaymentMethod("card", "Card")
val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer")
fun `should return empty list when only Error quotes`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider = createMockProvider("provider1", "Provider 1")
val quotes = listOf(
createMockErrorQuote(paymentMethod, provider),
createMockErrorQuote(paymentMethod, provider),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers -> Truth.assertThat(offers).isEmpty() },
)
}
}
@Test
fun `should group offers by payment method with best rate marked`() = runTest {
val paymentMethod1 = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val quotes = listOf(
createMockQuote(paymentMethod1, provider1, BigDecimal("100.0")),
createMockQuote(paymentMethod1, provider2, BigDecimal("95.0")),
createMockQuote(paymentMethod2, provider1, BigDecimal("98.0")),
createMockDataQuote(paymentMethod1, provider1, BigDecimal("100.0")),
createMockDataQuote(paymentMethod1, provider2, BigDecimal("95.0")),
createMockDataQuote(paymentMethod2, provider1, BigDecimal("98.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
@ -86,6 +114,7 @@ class GetOnrampAllOffersUseCaseTest {
Truth.assertThat(cardGroup?.offers).hasSize(2)
Truth.assertThat(cardGroup?.providerCount).isEqualTo(2)
Truth.assertThat(cardGroup?.isBestPaymentMethod).isTrue()
Truth.assertThat(cardGroup?.methodStatus).isEqualTo(PaymentMethodStatus.Available)
val bestRateOffer = cardGroup?.offers?.find { it.advantages == OnrampOfferAdvantages.BestRate }
Truth.assertThat(bestRateOffer).isNotNull()
@ -95,6 +124,7 @@ class GetOnrampAllOffersUseCaseTest {
Truth.assertThat(bankGroup?.offers).hasSize(1)
Truth.assertThat(bankGroup?.providerCount).isEqualTo(1)
Truth.assertThat(bankGroup?.isBestPaymentMethod).isFalse()
Truth.assertThat(bankGroup?.methodStatus).isEqualTo(PaymentMethodStatus.Available)
},
)
}
@ -104,14 +134,14 @@ class GetOnrampAllOffersUseCaseTest {
}
@Test
fun `invoke should sort offers by toAmount descending`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card")
fun `should sort offers by toAmount descending`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider = createMockProvider("provider1", "Provider 1")
val quotes = listOf(
createMockQuote(paymentMethod, provider, BigDecimal("90.0")),
createMockQuote(paymentMethod, provider, BigDecimal("100.0")),
createMockQuote(paymentMethod, provider, BigDecimal("95.0")),
createMockDataQuote(paymentMethod, provider, BigDecimal("90.0")),
createMockDataQuote(paymentMethod, provider, BigDecimal("100.0")),
createMockDataQuote(paymentMethod, provider, BigDecimal("95.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
@ -144,13 +174,248 @@ class GetOnrampAllOffersUseCaseTest {
}
}
private fun createMockPaymentMethod(id: String, name: String): OnrampPaymentMethod {
@Test
fun `should include AmountError offers in group`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val quotes = listOf(
createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")),
createMockAmountErrorQuote(paymentMethod, provider2),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(cardGroup.offers).hasSize(2)
Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available)
val dataOffer = cardGroup.offers.find { it.quote is OnrampQuote.Data }
Truth.assertThat(dataOffer).isNotNull()
Truth.assertThat(dataOffer?.rateDif).isNull() // Best rate offer has null rateDif
val amountErrorOffer = cardGroup.offers.find { it.quote is OnrampQuote.AmountError }
Truth.assertThat(amountErrorOffer).isNotNull()
Truth.assertThat(amountErrorOffer?.rateDif).isNull()
Truth.assertThat(amountErrorOffer?.advantages).isEqualTo(OnrampOfferAdvantages.Default)
},
)
}
}
@Test
fun `should mark payment method as Unavailable when only AmountError offers`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val quotes = listOf(
createMockAmountErrorQuote(paymentMethod, provider1, requiredAmount = BigDecimal("100.0")),
createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("50.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(
cardGroup.methodStatus,
).isEqualTo(PaymentMethodStatus.Unavailable(BigDecimal("50.0")))
Truth.assertThat(cardGroup.offers).hasSize(2)
Truth.assertThat(cardGroup.bestRateOffer).isNotNull()
val bestQuote = cardGroup.bestRateOffer?.quote as? OnrampQuote.AmountError
Truth.assertThat(bestQuote?.error?.requiredAmount).isEqualTo(BigDecimal("50.0"))
Truth.assertThat(cardGroup.isBestPaymentMethod).isFalse()
cardGroup.offers.forEach { offer ->
Truth.assertThat(offer.quote).isInstanceOf(OnrampQuote.AmountError::class.java)
Truth.assertThat(offer.rateDif).isNull()
}
},
)
}
}
@Test
fun `should not include Error quotes in groups`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val quotes = listOf(
createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")),
createMockErrorQuote(paymentMethod, provider2),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(cardGroup.offers).hasSize(1)
Truth.assertThat(cardGroup.offers.first().quote).isInstanceOf(OnrampQuote.Data::class.java)
Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available)
},
)
}
}
@Test
fun `should prioritize Data over AmountError for best rate`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val provider3 = createMockProvider("provider3", "Provider 3")
val quotes = listOf(
createMockDataQuote(paymentMethod, provider1, BigDecimal("90.0")),
createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("10.0")),
createMockDataQuote(paymentMethod, provider3, BigDecimal("100.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(cardGroup.offers).hasSize(3)
Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available)
Truth.assertThat(cardGroup.bestRateOffer).isNotNull()
val bestQuote = cardGroup.bestRateOffer?.quote as? OnrampQuote.Data
Truth.assertThat(bestQuote?.toAmount?.value).isEqualTo(BigDecimal("100.0"))
},
)
}
}
@Test
fun `should sort AmountError offers to bottom`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val provider3 = createMockProvider("provider3", "Provider 3")
val quotes = listOf(
createMockAmountErrorQuote(paymentMethod, provider1, requiredAmount = BigDecimal("50.0")),
createMockDataQuote(paymentMethod, provider2, BigDecimal("95.0")),
createMockDataQuote(paymentMethod, provider3, BigDecimal("100.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(cardGroup.offers).hasSize(3)
Truth.assertThat(cardGroup.offers[0].quote).isInstanceOf(OnrampQuote.Data::class.java)
Truth.assertThat(cardGroup.offers[1].quote).isInstanceOf(OnrampQuote.Data::class.java)
Truth.assertThat(cardGroup.offers[2].quote).isInstanceOf(OnrampQuote.AmountError::class.java)
val firstAmount = (cardGroup.offers[0].quote as OnrampQuote.Data).toAmount.value
val secondAmount = (cardGroup.offers[1].quote as OnrampQuote.Data).toAmount.value
Truth.assertThat(firstAmount).isEqualTo(BigDecimal("100.0"))
Truth.assertThat(secondAmount).isEqualTo(BigDecimal("95.0"))
},
)
}
}
@Test
fun `should handle mixed Data AmountError and Error quotes correctly`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
val provider3 = createMockProvider("provider3", "Provider 3")
val provider4 = createMockProvider("provider4", "Provider 4")
val quotes = listOf(
createMockDataQuote(paymentMethod, provider1, BigDecimal("100.0")),
createMockAmountErrorQuote(paymentMethod, provider2, requiredAmount = BigDecimal("50.0")),
createMockErrorQuote(paymentMethod, provider3),
createMockDataQuote(paymentMethod, provider4, BigDecimal("95.0")),
)
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
val result = useCase(userWalletId, cryptoCurrencyId)
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val cardGroup = offers.first()
Truth.assertThat(cardGroup.offers).hasSize(3)
Truth.assertThat(cardGroup.methodStatus).isEqualTo(PaymentMethodStatus.Available)
val dataOffers = cardGroup.offers.filter { it.quote is OnrampQuote.Data }
val amountErrorOffers = cardGroup.offers.filter { it.quote is OnrampQuote.AmountError }
val errorOffers = cardGroup.offers.filter { it.quote is OnrampQuote.Error }
Truth.assertThat(dataOffers).hasSize(2)
Truth.assertThat(amountErrorOffers).hasSize(1)
Truth.assertThat(errorOffers).isEmpty()
},
)
}
}
private fun createMockPaymentMethod(id: String, name: String, type: PaymentMethodType): OnrampPaymentMethod {
return mockk<OnrampPaymentMethod> {
every { this@mockk.id } returns id
every { this@mockk.name } returns name
every { this@mockk.type } returns mockk {
every { getPriority(any()) } returns 1
}
every { this@mockk.type } returns type
}
}
@ -161,7 +426,7 @@ class GetOnrampAllOffersUseCaseTest {
}
}
private fun createMockQuote(
private fun createMockDataQuote(
paymentMethod: OnrampPaymentMethod,
provider: OnrampProvider,
toAmount: BigDecimal,
@ -169,9 +434,44 @@ class GetOnrampAllOffersUseCaseTest {
return mockk<OnrampQuote.Data> {
every { this@mockk.paymentMethod } returns paymentMethod
every { this@mockk.provider } returns provider
every { this@mockk.toAmount } returns mockk {
every { value } returns toAmount
every { this@mockk.toAmount } returns createMockAmount(toAmount)
every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0"))
every { this@mockk.countryCode } returns "US"
every { this@mockk.minFromAmount } returns null
every { this@mockk.maxFromAmount } returns null
}
}
private fun createMockAmountErrorQuote(
paymentMethod: OnrampPaymentMethod,
provider: OnrampProvider,
requiredAmount: BigDecimal = BigDecimal("50.0"),
): OnrampQuote.AmountError {
return mockk<OnrampQuote.AmountError> {
every { this@mockk.paymentMethod } returns paymentMethod
every { this@mockk.provider } returns provider
every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0"))
every { this@mockk.countryCode } returns "US"
every { this@mockk.error } returns mockk<OnrampError.AmountError> {
every { this@mockk.requiredAmount } returns requiredAmount
}
}
}
private fun createMockErrorQuote(paymentMethod: OnrampPaymentMethod, provider: OnrampProvider): OnrampQuote.Error {
return mockk<OnrampQuote.Error> {
every { this@mockk.paymentMethod } returns paymentMethod
every { this@mockk.provider } returns provider
every { this@mockk.fromAmount } returns createMockAmount(BigDecimal("10.0"))
every { this@mockk.countryCode } returns "US"
every { this@mockk.error } returns mockk<OnrampError>()
}
}
private fun createMockAmount(value: BigDecimal): OnrampAmount {
return mockk<OnrampAmount> {
every { this@mockk.value } returns value
every { this@mockk.symbol } returns "USD"
}
}
}

View file

@ -285,7 +285,7 @@ class GetOnrampOffersUseCaseTest {
every { this@mockk.providerName } returns providerName
every { this@mockk.paymentMethod } returns paymentMethod
every { this@mockk.timestamp } returns timestamp
every { this@mockk.status.isTerminal } returns true
every { this@mockk.status } returns OnrampStatus.Status.Finished
}
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
@ -14,6 +15,7 @@ import com.tangem.features.onramp.mainv2.entity.OnrampOfferCategoryUM
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns.MINUS
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
@ -23,42 +25,64 @@ internal class AllOffersStateFactory(
private val allOffersIntents: AllOffersIntents,
) {
fun getLoadedPaymentsState(methodGroups: List<OnrampPaymentMethodGroup>): AllOffersStateUM {
fun getLoadedPaymentsState(methodGroups: List<OnrampPaymentMethodGroup>, currencyCode: String): AllOffersStateUM {
return AllOffersStateUM.Content(
methods = methodGroups.map { methodGroup ->
AllOffersPaymentMethodUM(
offers = mapOffersToUM(methodGroup.offers).toPersistentList(),
methodConfig = OnrampPaymentMethodConfig(
method = methodGroup.paymentMethod,
onClick = { allOffersIntents.onPaymentMethodClicked(methodGroup.paymentMethod.id) },
),
diff = methodGroup
.bestRateOffer
?.rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff ->
stringReference("$MINUS${diff.format { percent() }}")
},
rate = methodGroup.bestRateOffer?.let { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> quote.toAmount.value.format {
crypto(
symbol = quote.toAmount.symbol,
decimals = quote.toAmount.decimals,
)
}
else -> ""
}
} ?: "",
providersCount = methodGroup.providerCount,
isBestRate = methodGroup.isBestPaymentMethod,
)
methods = methodGroups.map { group ->
createPaymentMethodUM(group, currencyCode)
}.toPersistentList(),
currentMethod = null,
currentMethod = replaceOffersForCurrentMethod(methodGroups, currencyCode),
onBackClicked = { allOffersIntents.onBackClicked() },
)
}
private fun createPaymentMethodUM(
methodGroup: OnrampPaymentMethodGroup,
currencyCode: String,
): AllOffersPaymentMethodUM {
return AllOffersPaymentMethodUM(
offers = mapOffersToUM(methodGroup.offers, currencyCode).toPersistentList(),
methodConfig = createMethodConfig(methodGroup.paymentMethod),
diff = formatRateDiff(methodGroup.bestRateOffer?.rateDif),
rate = formatBestRate(methodGroup.bestRateOffer, currencyCode),
providersCount = methodGroup.providerCount,
isBestRate = methodGroup.isBestPaymentMethod,
paymentMethodStatus = methodGroup.methodStatus,
)
}
private fun createMethodConfig(paymentMethod: OnrampPaymentMethod): OnrampPaymentMethodConfig {
return OnrampPaymentMethodConfig(
method = paymentMethod,
onClick = { allOffersIntents.onPaymentMethodClicked(paymentMethod.id) },
)
}
private fun formatRateDiff(rateDif: BigDecimal?) = rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff -> stringReference("$MINUS${diff.format { percent() }}") }
private fun formatBestRate(bestRateOffer: OnrampOffer?, currencyCode: String): String {
return when (val quote = bestRateOffer?.quote) {
is OnrampQuote.Data -> formatCryptoAmount(quote.toAmount)
is OnrampQuote.AmountError -> formatRequiredAmount(quote, currencyCode)
is OnrampQuote.Error,
null,
-> ""
}
}
private fun formatCryptoAmount(amount: OnrampAmount): String {
return amount.value.format {
crypto(symbol = amount.symbol, decimals = amount.decimals)
}
}
private fun formatRequiredAmount(quote: OnrampQuote.AmountError, currencyCode: String): String {
return quote.error.requiredAmount.format {
fiat(fiatCurrencySymbol = quote.fromAmount.symbol, fiatCurrencyCode = currencyCode)
}
}
fun getPaymentsState(): AllOffersStateUM {
return when (val currentState = currentStateProvider.invoke()) {
is AllOffersStateUM.Content -> {
@ -72,34 +96,36 @@ internal class AllOffersStateFactory(
}
fun getOnrampErrorState(onrampError: OnrampError): AllOffersStateUM {
return when (onrampError) {
is OnrampError.DataError -> getErrorState(
errorCode = onrampError.code,
onRefresh = allOffersIntents::onRefresh,
)
OnrampError.PairsNotFound,
is OnrampError.DomainError,
-> getErrorState(onRefresh = allOffersIntents::onRefresh)
is OnrampError.AmountError.TooBigError,
is OnrampError.AmountError.TooSmallError,
OnrampError.RedirectError.VerificationFailed,
OnrampError.RedirectError.WrongRequestId,
-> currentStateProvider()
return if (shouldShowErrorState(onrampError)) {
createErrorState(errorCode = (onrampError as? OnrampError.DataError)?.code)
} else {
currentStateProvider()
}
}
private fun getErrorState(errorCode: String? = null, onRefresh: () -> Unit): AllOffersStateUM {
val state = currentStateProvider()
return when (state) {
private fun shouldShowErrorState(error: OnrampError): Boolean {
return when (error) {
is OnrampError.DataError,
OnrampError.PairsNotFound,
is OnrampError.DomainError,
-> true
is OnrampError.AmountError,
is OnrampError.RedirectError,
-> false
}
}
private fun createErrorState(errorCode: String? = null): AllOffersStateUM {
return when (currentStateProvider()) {
is AllOffersStateUM.Content,
AllOffersStateUM.Loading,
-> AllOffersStateUM.Error(
errorNotification = NotificationUM.Warning.OnrampErrorNotification(
errorCode = errorCode,
onRefresh = onRefresh,
onRefresh = allOffersIntents::onRefresh,
),
)
is AllOffersStateUM.Error -> state
is AllOffersStateUM.Error -> currentStateProvider()
}
}
@ -108,52 +134,70 @@ internal class AllOffersStateFactory(
OnrampOfferAdvantages.Default -> OnrampOfferAdvantagesUM.Default
OnrampOfferAdvantages.BestRate -> OnrampOfferAdvantagesUM.BestRate
OnrampOfferAdvantages.Fastest -> OnrampOfferAdvantagesUM.Fastest
OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.Default
OnrampOfferAdvantages.GreatRate -> OnrampOfferAdvantagesUM.GreatRate
}
}
private fun mapOffersToUM(offers: List<OnrampOffer>): List<OnrampOfferUM> {
return buildList {
offers.forEach { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> {
add(
OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerName = quote.provider.info.name,
rate = quote.toAmount.value.format {
crypto(
symbol = quote.toAmount.symbol,
decimals = quote.toAmount.decimals,
)
},
diff = offer
.rateDif
?.takeIf { it > BigDecimal.ZERO }
?.let { diff ->
stringReference("$MINUS${diff.format { percent() }}")
},
onBuyClicked = {
allOffersIntents.onBuyClick(
quote = OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages),
)
},
),
)
}
is OnrampQuote.AmountError,
is OnrampQuote.Error,
-> Unit
}
private fun mapOffersToUM(offers: List<OnrampOffer>, currencyCode: String): List<OnrampOfferUM> {
return offers.mapNotNull { offer ->
when (val quote = offer.quote) {
is OnrampQuote.Data -> createDataOfferUM(quote, offer)
is OnrampQuote.AmountError -> createAmountErrorOfferUM(quote, offer, currencyCode)
is OnrampQuote.Error -> null
}
}
}
private fun createDataOfferUM(quote: OnrampQuote.Data, offer: OnrampOffer): OnrampOfferUM {
return OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = mapOfferAdvantagesDTOtoUM(offer.advantages),
paymentMethod = quote.paymentMethod,
providerName = quote.provider.info.name,
rate = formatCryptoAmount(quote.toAmount),
diff = formatRateDiff(offer.rateDif),
onBuyClicked = {
allOffersIntents.onBuyClick(
quote = OnrampProviderWithQuote.Data(
provider = quote.provider,
paymentMethod = quote.paymentMethod,
toAmount = quote.toAmount,
fromAmount = quote.fromAmount,
),
onrampOfferAdvantagesUM = mapOfferAdvantagesDTOtoUM(offer.advantages),
)
},
)
}
private fun createAmountErrorOfferUM(
quote: OnrampQuote.AmountError,
offer: OnrampOffer,
currencyCode: String,
): OnrampOfferUM {
return OnrampOfferUM(
category = OnrampOfferCategoryUM.Recommended,
advantages = OnrampOfferAdvantagesUM.Unavailable,
paymentMethod = quote.paymentMethod,
providerName = quote.provider.info.name,
rate = formatRequiredAmount(quote, currencyCode),
diff = formatRateDiff(offer.rateDif),
onBuyClicked = {},
)
}
private fun replaceOffersForCurrentMethod(
methodGroups: List<OnrampPaymentMethodGroup>,
currencyCode: String,
): AllOffersPaymentMethodUM? {
val currentMethod = (currentStateProvider() as? AllOffersStateUM.Content)?.currentMethod ?: return null
val updateForCurrentMethod = methodGroups.find { it.paymentMethod.id == currentMethod.methodConfig.method.id }
return updateForCurrentMethod?.let { updatedCurrentMethod ->
currentMethod.copy(
offers = mapOffersToUM(updatedCurrentMethod.offers, currencyCode).toImmutableList(),
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.onramp.alloffers.entity
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.features.onramp.mainv2.entity.OnrampOfferUM
import kotlinx.collections.immutable.ImmutableList
@ -26,6 +27,7 @@ internal data class AllOffersPaymentMethodUM(
val rate: String,
val providersCount: Int,
val isBestRate: Boolean,
val paymentMethodStatus: PaymentMethodStatus,
)
internal data class OnrampPaymentMethodConfig(

View file

@ -74,6 +74,7 @@ internal class AllOffersModel @Inject constructor(
providerName = quote.provider.info.name,
paymentMethodName = quote.paymentMethod.name,
)?.let { analyticsEventHandler::send }
dismiss()
params.openRedirectPage(quote)
}
@ -95,7 +96,12 @@ internal class AllOffersModel @Inject constructor(
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offersGroup ->
_state.update { stateFactory.getLoadedPaymentsState(offersGroup) }
_state.update {
stateFactory.getLoadedPaymentsState(
methodGroups = offersGroup,
currencyCode = params.amountCurrencyCode,
)
}
},
)
}

View file

@ -26,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.AllOffersStateUM
@ -237,6 +238,7 @@ private fun AllOffersContentSheetPaymentPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
TangemThemePreview {
@ -302,6 +304,7 @@ private fun AllOffersContentSheetOffersPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
}

View file

@ -28,6 +28,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SelectPaymentMethodBottomSheetTestTags
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.PaymentMethodStatus
import com.tangem.domain.onramp.model.PaymentMethodType
import com.tangem.features.onramp.alloffers.entity.AllOffersPaymentMethodUM
import com.tangem.features.onramp.alloffers.entity.OnrampPaymentMethodConfig
@ -89,21 +90,50 @@ private fun PaymentMethod(methodUM: AllOffersPaymentMethodUM, modifier: Modifier
SpacerW(12.dp)
Column {
PaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
rate = methodUM.rate,
diff = methodUM.diff,
isBestRate = methodUM.isBestRate,
)
Row(verticalAlignment = Alignment.CenterVertically) {
ProvidersCountBlockInfo(providersCount = methodUM.providersCount)
SpacerW(8.dp)
TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed())
when (methodUM.paymentMethodStatus) {
PaymentMethodStatus.Available -> {
PaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
rate = methodUM.rate,
diff = methodUM.diff,
isBestRate = methodUM.isBestRate,
)
Row(verticalAlignment = Alignment.CenterVertically) {
ProvidersCountBlockInfo(providersCount = methodUM.providersCount)
SpacerW(8.dp)
TimingBlockInfo(speed = methodUM.methodConfig.method.type.getProcessingSpeed())
}
}
is PaymentMethodStatus.Unavailable -> {
UnavailablePaymentMethodInfoBlock(
paymentMethodName = methodUM.methodConfig.method.name,
errorAmount = methodUM.rate,
)
}
}
}
}
}
@Composable
private fun UnavailablePaymentMethodInfoBlock(paymentMethodName: String, errorAmount: String) {
Column(modifier = Modifier.padding(bottom = 14.dp)) {
Text(
text = paymentMethodName,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.PAYMENT_METHOD_NAME),
)
SpacerH(2.dp)
Text(
text = stringResourceSafe(R.string.onramp_provider_min_amount, errorAmount),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.testTag(SelectPaymentMethodBottomSheetTestTags.UP_TO_TEXT),
)
}
}
@Composable
private fun PaymentMethodInfoBlock(
paymentMethodName: String,
@ -267,6 +297,7 @@ private fun PaymentMethodsContentPreview() {
rate = "0,0245334 BTC",
providersCount = 2,
isBestRate = true,
paymentMethodStatus = PaymentMethodStatus.Available,
)
TangemThemePreview {
PaymentMethodsContent(persistentListOf(method))

View file

@ -67,7 +67,10 @@ internal class DefaultOnrampV2MainComponent @AssistedInject constructor(
cryptoCurrency = params.cryptoCurrency,
country = config.country,
launchSepa = false,
onDismiss = { model.bottomSheetNavigation.dismiss() },
onDismiss = {
model.bottomSheetNavigation.dismiss()
model.handleOnrampAvailable()
},
),
)
is OnrampV2MainBottomSheetConfig.CurrenciesList -> selectCurrencyComponentFactory.create(

View file

@ -67,7 +67,7 @@ internal class OnrampV2AmountStateFactory(
)
}
fun getAmountSecondaryFieldUpdatedState(quotes: List<OnrampQuote>): OnrampV2MainComponentUM {
fun getSecondaryFieldAmountErrorState(quotes: List<OnrampQuote>): OnrampV2MainComponentUM {
val currentState = currentStateProvider()
if (currentState !is OnrampV2MainComponentUM.Content) return currentState
@ -82,6 +82,7 @@ internal class OnrampV2AmountStateFactory(
?: OnrampSecondaryFieldErrorUM.Empty,
),
errorNotification = null,
offersBlockState = OnrampOffersBlockUM.Empty,
)
}

View file

@ -229,9 +229,7 @@ internal class OnrampV2MainComponentModel @Inject constructor(
maybeOffers.fold(
ifLeft = ::handleOnrampError,
ifRight = { offers ->
if (offers.isEmpty()) {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
} else {
if (offers.isNotEmpty()) {
_state.update { onrampOffersStateFactory.getOffersState(offers) }
}
},
@ -285,10 +283,16 @@ internal class OnrampV2MainComponentModel @Inject constructor(
private fun handleQuoteResult(quotes: List<OnrampQuote>) {
sendOnrampQuotesErrorAnalytic(quotes)
if (quotes.all { it is OnrampQuote.AmountError }) {
_state.update { amountStateFactory.getAmountSecondaryFieldUpdatedState(quotes) }
} else {
_state.update { amountStateFactory.getAmountSecondaryFieldResetState() }
when {
quotes.isEmpty() -> {
_state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
quotes.all { it is OnrampQuote.AmountError } -> {
_state.update { amountStateFactory.getSecondaryFieldAmountErrorState(quotes) }
}
else -> {
_state.update { amountStateFactory.getAmountSecondaryFieldResetState() }
}
}
}

View file

@ -73,7 +73,8 @@ private fun InitialLoading(state: OnrampV2MainComponentUM.InitialLoading, modifi
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
.wrapContentHeight()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()

View file

@ -21,7 +21,7 @@ internal fun PaymentMethodIcon(imageUrl: String, modifier: Modifier = Modifier)
.size(TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8)
.background(TangemColorPalette.Light1)
.padding(TangemTheme.dimens.spacing6)
.padding(TangemTheme.dimens.spacing4)
.testTag(SelectProviderBottomSheetTestTags.PAYMENT_METHOD_ICON),
model = ImageRequest.Builder(context = LocalContext.current)
.data(imageUrl)