Updated on 2026-08-14

This commit is contained in:
Tangem 2024-11-25 14:14:58 +05:00
parent 44231e988d
commit e72c2c87bc
28 changed files with 426 additions and 105 deletions

View file

@ -1,19 +1,12 @@
package com.tangem.data.onramp
import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.onramp.model.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
// TODO: [REDACTED_JIRA]
internal class DefaultOnrampErrorResolver : OnrampErrorResolver {
private val onrampErrorConverter = OnrampErrorConverter()
override fun resolve(throwable: Throwable): OnrampError {
return if (throwable is ApiResponseError.HttpException) {
onrampErrorConverter.convert(throwable.errorBody.orEmpty())
} else {
OnrampError.UnknownError
}
return OnrampError.UnknownError
}
}

View file

@ -5,13 +5,19 @@ import com.tangem.data.onramp.converters.CountryConverter
import com.tangem.data.onramp.converters.CurrencyConverter
import com.tangem.data.onramp.converters.PaymentMethodConverter
import com.tangem.data.onramp.converters.StatusConverter
import com.tangem.data.onramp.converters.error.OnrampQuotesErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.TangemExpressValues
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.common.OnrampDestinationDTO
import com.tangem.datasource.api.onramp.models.request.OnrampPairsRequest
import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampCurrencyDTO
import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
@ -20,27 +26,30 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.OnrampPaymentMethod
import com.tangem.domain.onramp.model.OnrampStatus
import com.tangem.domain.onramp.model.*
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
@Suppress("LongParameterList")
internal class DefaultOnrampRepository(
private val onrampApi: OnrampApi,
private val expressApi: TangemExpressApi,
private val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val paymentMethodsStore: OnrampPaymentMethodsStore,
private val pairsStore: OnrampPairsStore,
private val quotesStore: OnrampQuotesStore,
private val quotesErrorConverter: OnrampQuotesErrorConverter,
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
@ -120,77 +129,98 @@ internal class DefaultOnrampRepository(
throw it
},
)
paymentMethodsStore.store(PAYMENT_METHODS_KEY, response)
paymentMethodsStore.store(PAYMENT_METHODS_KEY, response.removeApplePay())
}
override suspend fun fetchPairs(currency: OnrampCurrency, country: OnrampCountry, cryptoCurrency: CryptoCurrency) =
withContext(dispatchers.io) {
val pairs = safeApiCall(
call = {
onrampApi.getPairs(
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
val onrampPairs = async {
safeApiCall(
call = {
onrampApi.getPairs(
body = OnrampPairsRequest(
fromCurrencyCode = currency.code,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.backendId,
),
),
),
),
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
pairsStore.store(PAIRS_KEY, pairs)
).bind()
},
onError = {
Timber.w(it, "Unable to fetch onramp pairs")
throw it
},
)
}
val providers = async {
safeApiCall(
call = { expressApi.getProviders().bind() },
onError = {
Timber.w(it, "Unable to fetch express providers")
throw it
},
)
}
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
}
override suspend fun fetchQuotes(
currency: OnrampCurrency,
country: OnrampCountry,
cryptoCurrency: CryptoCurrency,
fromAmount: String,
) = withContext(dispatchers.io) {
override suspend fun fetchQuotes(cryptoCurrency: CryptoCurrency, amount: Amount) = withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
"Unable to get pairs. At this point they must not be null."
}
val quotes = pairs.flatMap { pair ->
val amountValue = requireNotNull(amount.value) { "Amount value must not be null" }
val fromAmount = amountValue.movePointRight(amount.decimals).toString()
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val quotes: List<OnrampQuote> = pairs.flatMap { pair ->
pair.providers.flatMap { provider ->
provider.paymentMethods.map { paymentMethod ->
async {
safeApiCall(
call = {
onrampApi.getQuote(
val response = onrampApi.getQuote(
fromCurrencyCode = currency.code,
toContractAddress = cryptoCurrency.getContractAddress(),
toNetwork = cryptoCurrency.network.backendId,
paymentMethod = paymentMethod,
paymentMethod = paymentMethod.id,
countryCode = country.code,
fromAmount = fromAmount,
toDecimals = cryptoCurrency.decimals,
providerId = provider.providerId,
providerId = provider.id,
).bind()
},
onError = {
Timber.w(
it,
"Unable to fetch onramp quotes for ${provider.providerId} -> $paymentMethod",
OnrampQuote.Content(
fromAmount = convertToBigDecimal(response.fromAmount),
toAmount = convertToBigDecimal(response.toAmount),
minFromAmount = convertToBigDecimal(response.minFromAmount),
maxFromAmount = convertToBigDecimal(response.maxFromAmount),
paymentMethodId = response.paymentMethod,
providerId = response.providerId,
)
null
},
onError = { error ->
if (error is ApiResponseError.HttpException) {
quotesErrorConverter.convert(error.errorBody.orEmpty() to amount)
} else {
Timber.w(error, "Unable to fetch onramp quotes for ${provider.id}. $error")
null
}
},
)
}
}
}
}
.awaitAll()
.filterNotNull()
}.awaitAll().filterNotNull()
quotesStore.store(QUOTES_KEY, quotes)
}
override fun getQuotes(): Flow<List<OnrampQuote>> {
return quotesStore.get(QUOTES_KEY)
}
override suspend fun getPaymentMethods(): List<OnrampPaymentMethod> {
val paymentMethods = requireNotNull(paymentMethodsStore.getSyncOrNull(PAYMENT_METHODS_KEY)) {
"Onramp payment methods is absent in storage"
@ -198,17 +228,43 @@ internal class DefaultOnrampRepository(
return paymentMethodsConverter.convertList(paymentMethods)
}
override suspend fun clearCache() {
override suspend fun clearCache() = withContext(NonCancellable) {
paymentMethodsStore.clear()
pairsStore.clear()
quotesStore.clear()
}
private suspend fun storeOnrampPairs(pairs: List<OnrampPairDTO>, providers: List<ExchangeProvider>) {
val onrampPaymentMethods = getPaymentMethods()
val onrampPairs = pairs.map { pair ->
val onrampProviders = pair.providers.mapNotNull { onrampProviderDTO ->
val providerInfo = providers.find { provider ->
provider.id == onrampProviderDTO.providerId
} ?: return@mapNotNull null
OnrampProvider(
id = onrampProviderDTO.providerId,
info = OnrampProviderInfo(name = providerInfo.name, imageLarge = providerInfo.imageLargeUrl),
paymentMethods = onrampPaymentMethods.filter { paymentMethod ->
onrampProviderDTO.paymentMethods.any { paymentMethod.id == it }
},
)
}
OnrampPair(onrampProviders)
}
pairsStore.store(PAIRS_KEY, onrampPairs)
}
private fun CryptoCurrency.getContractAddress(): String = when (this) {
is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Token -> this.contractAddress
}
private fun convertToBigDecimal(value: String): BigDecimal =
requireNotNull(value.toBigDecimalOrNull()) { "Can not parse $value to BigDecimal" }
private fun List<PaymentMethodDTO>.removeApplePay(): List<PaymentMethodDTO> = filterNot { it.id == "apple-pay" }
private companion object {
const val PAYMENT_METHODS_KEY = "onramp_payment_methods"
const val PAIRS_KEY = "onramp_pairs"

View file

@ -1,11 +0,0 @@
package com.tangem.data.onramp.converters.error
import com.tangem.domain.onramp.model.OnrampError
import com.tangem.utils.converter.Converter
internal class OnrampErrorConverter : Converter<String, OnrampError> {
override fun convert(value: String): OnrampError {
return OnrampError.UnknownError
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.onramp.converters.error
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.api.express.models.response.ExpressError
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.utils.converter.Converter
internal class OnrampQuotesErrorConverter(
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
) : Converter<Pair<String, Amount>, OnrampQuote.Error?> {
@Suppress("MagicNumber")
override fun convert(value: Pair<String, Amount>): OnrampQuote.Error? {
val (errorResponse, amount) = value
try {
val error = jsonAdapter.fromJson(errorResponse)?.error ?: return null
return when (error.code) {
2250 -> tryParseExchangeTooSmallAmountError(error = error, amount = amount)
2251 -> tryParseExchangeTooBigAmountError(error = error, amount = amount)
else -> null
}
} catch (e: Exception) {
return null
}
}
private fun tryParseExchangeTooSmallAmountError(
error: ExpressError,
amount: Amount,
): OnrampQuote.Error.AmountTooSmallError? {
val minAmount = error.value?.minAmount ?: return null
val decimals = error.value?.decimals ?: return null
return OnrampQuote.Error.AmountTooSmallError(
amount = createFromAmountWithOffset(
amountWithOffset = minAmount,
decimals = decimals,
symbol = amount.currencySymbol,
type = amount.type,
),
)
}
private fun tryParseExchangeTooBigAmountError(
error: ExpressError,
amount: Amount,
): OnrampQuote.Error.AmountTooBigError? {
val maxAmount = error.value?.maxAmount ?: return null
val decimals = error.value?.decimals ?: return null
return OnrampQuote.Error.AmountTooBigError(
amount = createFromAmountWithOffset(
amountWithOffset = maxAmount,
decimals = decimals,
symbol = amount.currencySymbol,
type = amount.type,
),
)
}
private fun createFromAmountWithOffset(
amountWithOffset: String,
decimals: Int,
symbol: String,
type: AmountType,
): Amount {
return Amount(
value = requireNotNull(
amountWithOffset.toBigDecimalOrNull()?.movePointLeft(decimals),
) { "wrong amount format, use only digits" },
decimals = decimals,
currencySymbol = symbol,
type = type,
)
}
}

View file

@ -1,9 +1,14 @@
package com.tangem.data.onramp.di
import com.squareup.moshi.Moshi
import com.tangem.data.onramp.DefaultOnrampErrorResolver
import com.tangem.data.onramp.DefaultOnrampRepository
import com.tangem.data.onramp.DefaultOnrampTransactionRepository
import com.tangem.data.onramp.converters.error.OnrampQuotesErrorConverter
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
@ -26,19 +31,23 @@ internal object OnrampDataModule {
@Singleton
fun provideOnrampRepository(
onrampApi: OnrampApi,
expressApi: TangemExpressApi,
dispatchers: CoroutineDispatcherProvider,
appPreferencesStore: AppPreferencesStore,
paymentMethodsStore: OnrampPaymentMethodsStore,
pairsStore: OnrampPairsStore,
quotesStore: OnrampQuotesStore,
quotesErrorConverter: OnrampQuotesErrorConverter,
): OnrampRepository {
return DefaultOnrampRepository(
onrampApi = onrampApi,
expressApi = expressApi,
dispatchers = dispatchers,
appPreferencesStore = appPreferencesStore,
paymentMethodsStore = paymentMethodsStore,
pairsStore = pairsStore,
quotesStore = quotesStore,
quotesErrorConverter = quotesErrorConverter,
)
}
@ -59,4 +68,11 @@ internal object OnrampDataModule {
fun provideOnrampErrorResolver(): OnrampErrorResolver {
return DefaultOnrampErrorResolver()
}
@Provides
@Singleton
internal fun provideErrorsConverter(@NetworkMoshi moshi: Moshi): OnrampQuotesErrorConverter {
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
return OnrampQuotesErrorConverter(jsonAdapter)
}
}