Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-11 09:42:12 +03:00
parent 4df207e832
commit d443530171
10 changed files with 197 additions and 5 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import dagger.Module
import dagger.Provides
@ -264,12 +265,14 @@ internal object OnrampDomainModule {
onrampErrorResolver: OnrampErrorResolver,
onrampTransactionRepository: OnrampTransactionRepository,
settingsRepository: SettingsRepository,
promoRepository: PromoRepository,
): GetOnrampOffersUseCase {
return GetOnrampOffersUseCase(
onrampRepository = onrampRepository,
errorResolver = onrampErrorResolver,
onrampTransactionRepository = onrampTransactionRepository,
settingsRepository = settingsRepository,
promoRepository = promoRepository,
)
}
}

View file

@ -1,7 +1,10 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.promo.DefaultPromoBannerStore
import com.tangem.datasource.local.promo.DefaultPromoStoriesStore
import com.tangem.datasource.local.promo.PromoBannerStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import dagger.Module
import dagger.Provides
@ -18,4 +21,10 @@ object PromoStoreModule {
fun providePromoStoriesStore(): PromoStoriesStore {
return DefaultPromoStoriesStore(dataStore = RuntimeDataStore())
}
@Provides
@Singleton
fun providePromoBannerStore(): PromoBannerStore {
return DefaultPromoBannerStore(dataStore = RuntimeSharedStore())
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.local.promo
import com.tangem.datasource.api.promotion.models.PromoBannerResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
internal class DefaultPromoBannerStore(
private val dataStore: RuntimeSharedStore<Map<String, PromoBannerResponse>>,
) : PromoBannerStore {
override suspend fun getSyncOrNull(promoId: String): PromoBannerResponse? {
return dataStore.getSyncOrNull()?.get(promoId)
}
override suspend fun store(promoId: String, promoBanner: PromoBannerResponse) {
dataStore.update(emptyMap()) { current ->
current + (promoId to promoBanner)
}
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.datasource.local.promo
import com.tangem.datasource.api.promotion.models.PromoBannerResponse
interface PromoBannerStore {
suspend fun getSyncOrNull(promoId: String): PromoBannerResponse?
suspend fun store(promoId: String, promoBanner: PromoBannerResponse)
}

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.getShouldShowStor
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.promo.PromoBannerStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
@ -28,6 +29,7 @@ internal class DefaultPromoRepository(
private val tangemApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val promoStoriesStore: PromoStoriesStore,
private val promoBannerStore: PromoBannerStore,
private val dispatchers: CoroutineDispatcherProvider,
private val referralRepository: ReferralRepository,
) : PromoRepository {
@ -96,6 +98,18 @@ internal class DefaultPromoRepository(
)
}
override suspend fun isMoonpayPromoActive(): Boolean {
val banner = runCatching(dispatchers.io) {
val response = promoBannerStore.getSyncOrNull(MOONPAY_NAME) ?: run {
val apiResponse = tangemApi.getPromoBanner(MOONPAY_NAME).getOrThrow()
promoBannerStore.store(MOONPAY_NAME, apiResponse)
apiResponse
}
promoBannerConverter.convert(response)
}.getOrNull()
return banner?.isActive == true
}
override fun getStoryById(id: String): Flow<StoryContent?> = isReadyToShowStories(id).mapLatest {
getStoryByIdSync(id = id, refresh = false)
}
@ -166,6 +180,7 @@ internal class DefaultPromoRepository(
const val SEPA_NAME = "sepa"
const val VISA_NAME = "visa-waitlist"
const val BLACK_FRIDAY_NAME = "black-friday"
const val MOONPAY_NAME = "moonpay"
const val STORIES_LOAD_DELAY = 1000L
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.promo.di
import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promo.PromoBannerStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.promo.PromoRepository
import com.tangem.feature.referral.domain.ReferralRepository
@ -23,6 +24,7 @@ internal object PromoDataModule {
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
promoStoriesStore: PromoStoriesStore,
promoBannerStore: PromoBannerStore,
dispatchers: CoroutineDispatcherProvider,
referralRepository: ReferralRepository,
): PromoRepository {
@ -32,6 +34,7 @@ internal object PromoDataModule {
promoStoriesStore = promoStoriesStore,
dispatchers = dispatchers,
referralRepository = referralRepository,
promoBannerStore = promoBannerStore,
)
}
}

View file

@ -19,6 +19,7 @@ dependencies {
api(projects.domain.core)
api(projects.domain.settings)
implementation(deps.kotlin.serialization)
implementation(projects.domain.promo)
/** Tests */
testImplementation(deps.test.coroutine)

View file

@ -11,9 +11,11 @@ import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.onramp.utils.calculateRateDif
import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class GetOnrampOffersUseCase(
@ -21,14 +23,16 @@ class GetOnrampOffersUseCase(
private val onrampTransactionRepository: OnrampTransactionRepository,
private val errorResolver: OnrampErrorResolver,
private val settingsRepository: SettingsRepository,
private val promoRepository: PromoRepository,
) {
operator fun invoke(): EitherFlow<OnrampError, List<OnrampOffersBlock>> {
return combine(
onrampRepository.getQuotes(),
onrampTransactionRepository.getAllTransactions(),
) { quotes, transactions ->
processOffers(quotes, transactions)
flow { emit(promoRepository.isMoonpayPromoActive()) },
) { quotes, transactions, isMoonpayPromoActive ->
processOffers(quotes, transactions, isMoonpayPromoActive)
}
.map { offers -> offers.right() }
.catch { throwable -> errorResolver.resolve(throwable).left() }
@ -37,6 +41,7 @@ class GetOnrampOffersUseCase(
private suspend fun processOffers(
quotes: List<OnrampQuote>,
transactions: List<OnrampTransaction>,
isMoonpayPromoActive: Boolean,
): List<OnrampOffersBlock> {
val validQuotes = quotes.filterIsInstance<OnrampQuote.Data>()
if (validQuotes.isEmpty()) return emptyList()
@ -57,7 +62,7 @@ class GetOnrampOffersUseCase(
val recentOffer = findRecentOffer(offers, transactions)
val bestRateOffer = findBestRateOffer(offers, isGooglePayAvailable)
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable)
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable, isMoonpayPromoActive)
return buildOffersBlocks(
recentOffer = recentOffer,
@ -85,8 +90,24 @@ class GetOnrampOffersUseCase(
return offers.maxWithOrNull(offerComparator(isGooglePayAvailable))
}
private fun findFastestOffer(offers: List<OnrampOffer>, isGooglePayAvailable: Boolean): OnrampOffer? {
val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() }
private fun findFastestOffer(
offers: List<OnrampOffer>,
isGooglePayAvailable: Boolean,
isMoonpayPromoActive: Boolean,
): OnrampOffer? {
val moonpayPromoOffers = if (isMoonpayPromoActive) {
offers.filter {
it.quote.provider.id == MOONPAY_PROMO_PROVIDER_ID &&
it.quote.paymentMethod.type == PaymentMethodType.GOOGLE_PAY
}
} else {
emptyList()
}
val instantOffers = moonpayPromoOffers.ifEmpty {
offers.filter { it.quote.paymentMethod.type.isInstant() }
}
return if (instantOffers.isNotEmpty()) {
instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable))
} else {
@ -287,4 +308,8 @@ class GetOnrampOffersUseCase(
-> true
}
}
private companion object {
const val MOONPAY_PROMO_PROVIDER_ID = "moonpay"
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import io.mockk.*
import kotlinx.coroutines.flow.flowOf
@ -24,6 +25,7 @@ class GetOnrampOffersUseCaseTest {
private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true)
private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true)
private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true)
private val promoRepository: PromoRepository = mockk(relaxUnitFun = true)
private lateinit var useCase: GetOnrampOffersUseCase
@ -35,6 +37,7 @@ class GetOnrampOffersUseCaseTest {
onrampTransactionRepository = onrampTransactionRepository,
errorResolver = errorResolver,
settingsRepository = settingsRepository,
promoRepository = promoRepository,
)
}
@ -225,6 +228,7 @@ class GetOnrampOffersUseCaseTest {
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns false
coEvery { settingsRepository.isGooglePayAvailability() } returns false
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(
@ -245,6 +249,107 @@ class GetOnrampOffersUseCaseTest {
}
}
@Test
fun `invoke should fallback to standard instant offers when promo is active but no Moonpay offers exist`() =
runTest {
val instantMethod = createMockPaymentMethod("gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val provider = createMockProvider("other", "Other Provider")
val quotes = listOf(
createMockQuote(instantMethod, provider, BigDecimal("95.0")),
createMockQuote(slowMethod, provider, BigDecimal("100.0")),
)
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns true
coEvery { settingsRepository.isGooglePayAvailability() } returns true
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
val result = useCase()
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
Truth.assertThat(recommendedBlock).isNotNull()
Truth.assertThat(recommendedBlock?.offers).hasSize(2)
val fastestOffer =
recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
Truth.assertThat(fastestOffer).isNotNull()
when (val quote = fastestOffer?.quote) {
is OnrampQuote.Data -> {
Truth.assertThat(quote.provider.id).isNotEqualTo("moonpay")
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
}
else -> Truth.assertThat(false).isTrue()
}
},
)
}
}
@Test
fun `invoke should show Moonpay fastest offer when promo is active`() = runTest {
val moonpayGooglePayMethod = createMockPaymentMethod("moonpay-gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
val otherGooglePayMethod = createMockPaymentMethod(
"other-gpay",
"Other Google Pay",
PaymentMethodType.GOOGLE_PAY,
)
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val moonpayProvider = createMockProvider("moonpay", "Moonpay")
val otherProvider = createMockProvider("other", "Other Provider")
val quotes = listOf(
createMockQuote(moonpayGooglePayMethod, moonpayProvider, BigDecimal("100.0")),
createMockQuote(otherGooglePayMethod, otherProvider, BigDecimal("95.0")),
createMockQuote(slowMethod, otherProvider, BigDecimal("105.0")),
)
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns true
coEvery { settingsRepository.isGooglePayAvailability() } returns true
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
val result = useCase()
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
Truth.assertThat(recommendedBlock).isNotNull()
val fastestOffer = recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
Truth.assertThat(fastestOffer).isNotNull()
when (val quote = fastestOffer?.quote) {
is OnrampQuote.Data -> {
Truth.assertThat(quote.provider.id).isEqualTo("moonpay")
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0"))
}
else -> Truth.assertThat(false).isTrue()
}
},
)
}
}
private fun createMockPaymentMethod(
id: String,
name: String,

View file

@ -19,6 +19,8 @@ interface PromoRepository {
fun isMarketsStakingNotificationHideClicked(): Flow<Boolean>
suspend fun setMarketsStakingNotificationHideClicked()
suspend fun isMoonpayPromoActive(): Boolean
// endregion
// region Stories