Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-17 10:07:20 +02:00
parent 4197574fca
commit 7f587d170c
25 changed files with 348 additions and 571 deletions

View file

@ -15,7 +15,7 @@ data class OnrampOffer(
)
enum class OnrampOfferAdvantages {
Default, BestRate, Fastest,
Default, BestRate, Fastest, GreatRate,
}
enum class OnrampOfferCategory {

View file

@ -18,31 +18,73 @@ 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
SEPA -> 2
REVOLUT_PAY -> 3
REVOLUT_PAY -> 2
SEPA -> 3
OTHER -> 4
}
} else {
when (this) {
CARD -> 0
GOOGLE_PAY -> 1
CARD -> 2
REVOLUT_PAY -> 1
SEPA -> 2
REVOLUT_PAY -> 3
OTHER -> 3
GOOGLE_PAY -> 4
}
}
/**
* Get priority regardless of real speed. By business logic.
*/
@Suppress("MagicNumber")
fun getPriorityForMethod(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
}
}
@Suppress("MagicNumber")
fun getPriorityBySpeed(isGooglePayEnabled: Boolean): Int = if (isGooglePayEnabled) {
when (this) {
GOOGLE_PAY -> 0
REVOLUT_PAY -> 1
CARD -> 2
SEPA -> 3
OTHER -> 4
}
} else {
when (this) {
REVOLUT_PAY -> 0
CARD -> 1
SEPA -> 2
OTHER -> 3
GOOGLE_PAY -> 4
}
}
/**
* BE AWARE. HARDCODED. Returns the speed of transaction for payment method type.
*/
fun getProcessingSpeed(): PaymentSpeed = when (this) {
REVOLUT_PAY,
GOOGLE_PAY,
REVOLUT_PAY,
-> PaymentSpeed.Instant
CARD -> PaymentSpeed.FewMin
SEPA -> PaymentSpeed.FewDays

View file

@ -47,7 +47,12 @@ class GetOnrampOffersUseCase(
if (validQuotes.isEmpty()) return emptyList()
val isGooglePayAvailable = settingsRepository.isGooglePayAvailability()
val bestRateQuote = validQuotes.maxWithOrNull(compareOffersByRateSpeedAndPriority(isGooglePayAvailable))
val bestRateQuote = validQuotes.maxWithOrNull(
compareOffersByRateSpeedAndPriority(
isGooglePayAvailable = isGooglePayAvailable,
isSepaPrioritized = true,
),
)
val bestRate = bestRateQuote?.toAmount?.value
val offers = validQuotes.map { quote ->
@ -71,8 +76,9 @@ class GetOnrampOffersUseCase(
val lastTransaction = transactions.maxByOrNull { it.timestamp } ?: return null
return offers.find { offer ->
offer.quote.provider.id == lastTransaction.providerType &&
offer.quote.paymentMethod.id == lastTransaction.paymentMethod
offer.quote.provider.info.name == lastTransaction.providerName &&
offer.quote.paymentMethod.name == lastTransaction.paymentMethod &&
lastTransaction.status.isTerminal
}
}
@ -83,14 +89,14 @@ class GetOnrampOffersUseCase(
private fun findFastestOffer(offers: List<OnrampOffer>, isGooglePayAvailable: Boolean): OnrampOffer? {
val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() }
return if (instantOffers.isNotEmpty()) {
instantOffers.maxWithOrNull(offerComparator(isGooglePayAvailable))
instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable))
} else {
val offersBySpeed = offers.groupBy { offer ->
offer.quote.paymentMethod.type.getProcessingSpeed().speed
}
val fastestSpeed = offersBySpeed.keys.minOrNull() ?: return null
val fastestOffers = offersBySpeed[fastestSpeed] ?: return null
fastestOffers.maxWithOrNull(offerComparator(isGooglePayAvailable))
fastestOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable))
}
}
@ -99,7 +105,10 @@ class GetOnrampOffersUseCase(
is OnrampQuote.Data -> {
when (val quote2 = offer2.quote) {
is OnrampQuote.Data -> {
compareOffersByRateSpeedAndPriority(isGooglePayAvailable).compare(quote1, quote2)
compareOffersByRateSpeedAndPriority(
isGooglePayAvailable = isGooglePayAvailable,
isSepaPrioritized = true,
).compare(quote1, quote2)
}
else -> 1
}
@ -108,6 +117,30 @@ class GetOnrampOffersUseCase(
}
}
private fun fastestOfferComparator(isGooglePayAvailable: Boolean): Comparator<OnrampOffer> =
Comparator { offer1, offer2 ->
when (val quote1 = offer1.quote) {
is OnrampQuote.Data -> {
when (val quote2 = offer2.quote) {
is OnrampQuote.Data -> {
// For fastest offer, first compare by priority of speed
val priorityComparison = quote2.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable)
.compareTo(quote1.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable))
// If priorities are equal, compare by rate
if (priorityComparison != 0) {
priorityComparison
} else {
quote1.toAmount.value.compareTo(quote2.toAmount.value)
}
}
else -> 1
}
}
else -> -1
}
}
private fun buildOffersBlocks(
recentOffer: OnrampOffer?,
bestRateOffer: OnrampOffer?,
@ -143,7 +176,7 @@ class GetOnrampOffersUseCase(
)
}
if (recommendedOffers.isNotEmpty() && hasOnlyOneMethodAndProvider(allOffers).not()) {
if (recommendedOffers.isNotEmpty()) {
add(
OnrampOffersBlock(
category = OnrampOfferCategory.Recommended,
@ -161,10 +194,10 @@ class GetOnrampOffersUseCase(
fastestOffer: OnrampOffer?,
): OnrampOfferAdvantages {
if (isSameOffer(recentOffer, bestRateOffer) && isSameOffer(recentOffer, fastestOffer)) {
return OnrampOfferAdvantages.BestRate
return OnrampOfferAdvantages.GreatRate
}
if (isSameOffer(recentOffer, bestRateOffer)) {
return OnrampOfferAdvantages.BestRate
return OnrampOfferAdvantages.GreatRate
}
if (isSameOffer(recentOffer, fastestOffer)) {
return OnrampOfferAdvantages.Fastest
@ -182,7 +215,7 @@ class GetOnrampOffersUseCase(
bestRateOffer?.let { offer ->
add(
offer.copy(
advantages = OnrampOfferAdvantages.BestRate,
advantages = OnrampOfferAdvantages.GreatRate,
rateDif = null,
),
)
@ -191,7 +224,7 @@ class GetOnrampOffersUseCase(
if (bestRateOffer != null && !isSameOffer(bestRateOffer, recentOffer)) {
add(
bestRateOffer.copy(
advantages = OnrampOfferAdvantages.BestRate,
advantages = OnrampOfferAdvantages.GreatRate,
rateDif = null,
),
)
@ -211,12 +244,6 @@ class GetOnrampOffersUseCase(
}
}
private fun hasOnlyOneMethodAndProvider(offers: List<OnrampOffer>): Boolean {
val uniquePaymentMethods = offers.map { it.quote.paymentMethod.id }.distinct()
val uniqueProviders = offers.map { it.quote.provider.id }.distinct()
return uniquePaymentMethods.size == 1 && uniqueProviders.size == 1
}
private fun isSameOffer(offer1: OnrampOffer?, offer2: OnrampOffer?): Boolean {
if (offer1 == null || offer2 == null) return false
return offer1.quote.provider.id == offer2.quote.provider.id &&

View file

@ -19,7 +19,7 @@ class GetOnrampPaymentMethodsUseCase(
repository.getAvailablePaymentMethods()
.toList()
.sortedBy { it.type.getPriority(isGooglePayAvailable) }
.sortedBy { it.type.getPriorityBySpeed(isGooglePayAvailable) }
.toSet()
}.mapLeft(errorResolver::resolve)
}

View file

@ -26,7 +26,7 @@ class GetOnrampQuotesUseCase(
quotes.groupBy { it.paymentMethod.type }
.asSequence()
.sortedBy { it.key.getPriority(isGooglePayAvailable) }
.sortedBy { it.key.getPriorityBySpeed(isGooglePayAvailable) }
.sortByRate()
.toList()
.flatten()
@ -53,7 +53,6 @@ class GetOnrampQuotesUseCase(
when (val error = it.error) {
is OnrampError.AmountError.TooSmallError -> it.fromAmount.value - error.requiredAmount
is OnrampError.AmountError.TooBigError -> error.requiredAmount - it.fromAmount.value
else -> null
}
}
}

View file

@ -1,73 +0,0 @@
package com.tangem.domain.onramp
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.onramp.model.OnrampQuote
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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import java.util.Comparator
class GetOnrampV2QuotesUseCase(
private val settingsRepository: SettingsRepository,
private val repository: OnrampRepository,
private val errorResolver: OnrampErrorResolver,
) {
operator fun invoke(): Flow<Either<OnrampError, List<OnrampQuote>>> {
return repository.getQuotes()
.map<List<OnrampQuote>, Either<OnrampError, List<OnrampQuote>>> { quotes ->
val isGooglePayAvailable = settingsRepository.isGooglePayAvailability()
quotes.sortedWith(
Comparator.comparing<OnrampQuote, SortableBigDecimalWrapper> { quote ->
getQuoteSortPriority(quote)
}
.thenComparingInt { quote ->
quote.paymentMethod.type.getPriority(isGooglePayAvailable)
},
).right()
}
.catch {
emit(errorResolver.resolve(it).left())
}
}
private class SortableBigDecimalWrapper(
val value: BigDecimal?,
val negateForSort: Boolean = false,
) : Comparable<SortableBigDecimalWrapper> {
override fun compareTo(other: SortableBigDecimalWrapper): Int {
return when {
value == null && other.value == null -> 0
value == null -> 1
other.value == null -> -1
else -> {
val thisValue = if (negateForSort) value.negate() else value
val otherValue = if (other.negateForSort) other.value.negate() else other.value
thisValue.compareTo(otherValue)
}
}
}
}
private fun getQuoteSortPriority(quote: OnrampQuote): SortableBigDecimalWrapper {
return when (quote) {
is OnrampQuote.Data -> SortableBigDecimalWrapper(quote.toAmount.value, negateForSort = true)
is OnrampQuote.Error -> SortableBigDecimalWrapper(null)
is OnrampQuote.AmountError -> {
when (val error = quote.error) {
is OnrampError.AmountError.TooSmallError ->
SortableBigDecimalWrapper((quote.fromAmount.value - error.requiredAmount).abs())
is OnrampError.AmountError.TooBigError ->
SortableBigDecimalWrapper((error.requiredAmount - quote.fromAmount.value).abs())
}
}
}
}
}

View file

@ -1,34 +1,42 @@
package com.tangem.domain.onramp.utils
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.PaymentMethodType
import java.math.BigDecimal
internal fun calculateRateDif(currentTokenRate: BigDecimal, bestRate: BigDecimal?): BigDecimal? {
if (bestRate == null) return null
return BigDecimal.ONE - currentTokenRate / bestRate
if (currentTokenRate > bestRate) return null
val rateDif = BigDecimal.ONE - currentTokenRate / bestRate
return if (rateDif >= BigDecimal("0.01")) rateDif else null
}
internal fun compareOffersByRateSpeedAndPriority(isGooglePayAvailable: Boolean): Comparator<OnrampQuote.Data> {
/**
* @param isSepaPrioritized Sepa provider should be prioritized over all offers no matter what.
*/
internal fun compareOffersByRateSpeedAndPriority(
isGooglePayAvailable: Boolean,
isSepaPrioritized: Boolean = false,
): Comparator<OnrampQuote.Data> {
return Comparator { quote1, quote2 ->
val rateComparison = quote1
.toAmount
.value
.compareTo(quote2.toAmount.value)
if (isSepaPrioritized) {
val isQuote1Sepa = quote1.paymentMethod.type == PaymentMethodType.SEPA
val isQuote2Sepa = quote2.paymentMethod.type == PaymentMethodType.SEPA
if (isQuote1Sepa && !isQuote2Sepa) return@Comparator 1
if (!isQuote1Sepa && isQuote2Sepa) return@Comparator -1
}
val rateComparison = quote1.toAmount.value.compareTo(quote2.toAmount.value)
if (rateComparison != 0) return@Comparator rateComparison
val speedComparison =
quote2
.paymentMethod
.type
.getProcessingSpeed()
.speed
quote2.paymentMethod.type.getProcessingSpeed().speed
.compareTo(quote1.paymentMethod.type.getProcessingSpeed().speed)
if (speedComparison != 0) return@Comparator speedComparison
quote1
.paymentMethod
.type
.getPriority(isGooglePayAvailable)
.compareTo(quote2.paymentMethod.type.getPriority(isGooglePayAvailable))
quote2.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable)
.compareTo(quote1.paymentMethod.type.getPriorityBySpeed(isGooglePayAvailable))
}
}

View file

@ -67,8 +67,8 @@ class GetOnrampOffersUseCaseTest {
@Test
fun `invoke should return offers blocks with recent and recommended categories`() = runTest {
val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = true)
val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false)
val paymentMethod1 = createMockPaymentMethod("card", "Card", PaymentMethodType.GOOGLE_PAY)
val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
@ -78,7 +78,7 @@ class GetOnrampOffersUseCaseTest {
)
val transactions = listOf(
createMockTransaction("provider1", "card", 1000L),
createMockTransaction("Provider 1", "Card", 1000L),
)
coEvery { settingsRepository.isGooglePayAvailability() } returns false
@ -105,7 +105,7 @@ class GetOnrampOffersUseCaseTest {
Truth.assertThat(recommendedBlock).isNotNull()
Truth.assertThat(recommendedBlock?.offers).hasSize(1)
Truth.assertThat(recommendedBlock?.offers?.first()?.advantages)
.isEqualTo(OnrampOfferAdvantages.BestRate)
.isEqualTo(OnrampOfferAdvantages.GreatRate)
},
)
}
@ -113,8 +113,8 @@ class GetOnrampOffersUseCaseTest {
@Test
fun `invoke should find best rate offer correctly`() = runTest {
val paymentMethod1 = createMockPaymentMethod("card", "Card", isInstant = false)
val paymentMethod2 = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false)
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")
@ -145,10 +145,10 @@ class GetOnrampOffersUseCaseTest {
Truth.assertThat(recommendedBlock).isNotNull()
Truth.assertThat(recommendedBlock?.offers).hasSize(1)
val bestRateOffer = recommendedBlock?.offers?.first()
Truth.assertThat(bestRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.BestRate)
val grateRateOffer = recommendedBlock?.offers?.first()
Truth.assertThat(grateRateOffer?.advantages).isEqualTo(OnrampOfferAdvantages.GreatRate)
when (val quote = bestRateOffer?.quote) {
when (val quote = grateRateOffer?.quote) {
is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0"))
else -> Truth.assertThat(false).isTrue()
}
@ -159,8 +159,10 @@ class GetOnrampOffersUseCaseTest {
@Test
fun `invoke should find fastest offer correctly`() = runTest {
val instantPaymentMethod = createMockPaymentMethod("card", "Card", isInstant = true)
val slowPaymentMethod = createMockPaymentMethod("bank", "Bank Transfer", isInstant = false)
val instantPaymentMethod =
createMockPaymentMethod("card", "Card", PaymentMethodType.GOOGLE_PAY)
val slowPaymentMethod =
createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val provider1 = createMockProvider("provider1", "Provider 1")
val provider2 = createMockProvider("provider2", "Provider 2")
@ -190,17 +192,17 @@ class GetOnrampOffersUseCaseTest {
Truth.assertThat(recommendedBlock).isNotNull()
Truth.assertThat(recommendedBlock?.offers).hasSize(2)
val bestRateOffer = recommendedBlock
val grateRateOffer = recommendedBlock
?.offers
?.find { it.advantages == OnrampOfferAdvantages.BestRate }
?.find { it.advantages == OnrampOfferAdvantages.GreatRate }
val fastestOffer = recommendedBlock
?.offers
?.find { it.advantages == OnrampOfferAdvantages.Fastest }
Truth.assertThat(bestRateOffer).isNotNull()
Truth.assertThat(grateRateOffer).isNotNull()
Truth.assertThat(fastestOffer).isNotNull()
when (val quote = bestRateOffer?.quote) {
when (val quote = grateRateOffer?.quote) {
is OnrampQuote.Data -> Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0"))
else -> Truth.assertThat(false).isTrue()
}
@ -215,8 +217,8 @@ class GetOnrampOffersUseCaseTest {
}
@Test
fun `invoke should not show recommended block when only one method and provider`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", isInstant = false)
fun `invoke should show recommended block when only one method and provider`() = runTest {
val paymentMethod = createMockPaymentMethod("card", "Card", PaymentMethodType.CARD)
val provider = createMockProvider("provider1", "Provider 1")
val quotes = listOf(
@ -238,22 +240,22 @@ class GetOnrampOffersUseCaseTest {
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).isEmpty()
Truth.assertThat(offers).isNotEmpty()
Truth.assertThat(offers).hasSize(1)
},
)
}
}
private fun createMockPaymentMethod(id: String, name: String, isInstant: Boolean): OnrampPaymentMethod {
private fun createMockPaymentMethod(
id: String,
name: String,
type: PaymentMethodType = PaymentMethodType.CARD,
): OnrampPaymentMethod {
return mockk<OnrampPaymentMethod> {
every { this@mockk.id } returns id
every { this@mockk.name } returns name
every { this@mockk.type } returns mockk {
every { isInstant() } returns isInstant
every { getProcessingSpeed() } returns mockk {
every { speed } returns if (isInstant) 1 else 3
}
}
every { this@mockk.type } returns type
}
}
@ -278,11 +280,12 @@ class GetOnrampOffersUseCaseTest {
}
}
private fun createMockTransaction(providerType: String, paymentMethod: String, timestamp: Long): OnrampTransaction {
private fun createMockTransaction(providerName: String, paymentMethod: String, timestamp: Long): OnrampTransaction {
return mockk<OnrampTransaction> {
every { this@mockk.providerType } returns providerType
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
}
}
}