From d443530171d92e80a744bd91a333b53dcdae96db Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Dec 2025 09:42:12 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/OnrampDomainModule.kt | 3 + .../tangem/datasource/di/PromoStoreModule.kt | 9 ++ .../local/promo/DefaultPromoBannerStore.kt | 19 ++++ .../local/promo/PromoBannerStore.kt | 10 ++ .../data/promo/DefaultPromoRepository.kt | 15 +++ .../tangem/data/promo/di/PromoDataModule.kt | 3 + domain/onramp/build.gradle.kts | 1 + .../domain/onramp/GetOnrampOffersUseCase.kt | 35 +++++- .../onramp/GetOnrampOffersUseCaseTest.kt | 105 ++++++++++++++++++ .../tangem/domain/promo/PromoRepository.kt | 2 + 10 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promo/DefaultPromoBannerStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/promo/PromoBannerStore.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 0c20fd0993..34ffd4aaba 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -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, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/PromoStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/PromoStoreModule.kt index b8af7137ae..af846ebcbb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/PromoStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/PromoStoreModule.kt @@ -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()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promo/DefaultPromoBannerStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promo/DefaultPromoBannerStore.kt new file mode 100644 index 0000000000..6065fa079a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promo/DefaultPromoBannerStore.kt @@ -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>, +) : 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) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/promo/PromoBannerStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/promo/PromoBannerStore.kt new file mode 100644 index 0000000000..f951238bd5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/promo/PromoBannerStore.kt @@ -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) +} \ No newline at end of file diff --git a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt index beb55966c3..1e465aa020 100644 --- a/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt +++ b/data/promo/src/main/java/com/tangem/data/promo/DefaultPromoRepository.kt @@ -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 = 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 } } \ No newline at end of file diff --git a/data/promo/src/main/java/com/tangem/data/promo/di/PromoDataModule.kt b/data/promo/src/main/java/com/tangem/data/promo/di/PromoDataModule.kt index 69d489a2dc..2f0c689e53 100644 --- a/data/promo/src/main/java/com/tangem/data/promo/di/PromoDataModule.kt +++ b/data/promo/src/main/java/com/tangem/data/promo/di/PromoDataModule.kt @@ -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, ) } } \ No newline at end of file diff --git a/domain/onramp/build.gradle.kts b/domain/onramp/build.gradle.kts index 7823e8dcea..cd1f511092 100644 --- a/domain/onramp/build.gradle.kts +++ b/domain/onramp/build.gradle.kts @@ -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) diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt index 0b2aba9380..8bc240224a 100644 --- a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetOnrampOffersUseCase.kt @@ -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> { 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, transactions: List, + isMoonpayPromoActive: Boolean, ): List { val validQuotes = quotes.filterIsInstance() 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, isGooglePayAvailable: Boolean): OnrampOffer? { - val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() } + private fun findFastestOffer( + offers: List, + 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" + } } \ No newline at end of file diff --git a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt index 660735a8e7..5f249f97a2 100644 --- a/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt +++ b/domain/onramp/src/test/kotlin/com/tangem/domain/onramp/GetOnrampOffersUseCaseTest.kt @@ -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() + 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() + + 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() + + 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, diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt b/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt index d8ea76a512..6e7afbdb02 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt @@ -19,6 +19,8 @@ interface PromoRepository { fun isMarketsStakingNotificationHideClicked(): Flow suspend fun setMarketsStakingNotificationHideClicked() + + suspend fun isMoonpayPromoActive(): Boolean // endregion // region Stories