Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 19:32:44 +05:00
parent 33efe2ae52
commit 499b7c04ea
33 changed files with 1502 additions and 0 deletions

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
implementation(projects.domain.marketing.models)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,8 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.marketing.models
data class MarketingBanner(
val uiType: UiType,
val text: String?,
val iconUrl: String?,
val iconAlign: IconAlign?,
val bgColor: String?,
val deeplink: String?,
val isDismissible: Boolean,
) {
enum class UiType { STANDALONE, LINKED_TO_PROVIDER }
enum class IconAlign { LEFT, RIGHT }
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.marketing.models
import java.math.BigDecimal
data class MarketingCampaign(
val id: Int,
val type: MarketingScreenType,
val priority: Int,
val startAt: String?,
val endAt: String?,
val minAmount: BigDecimal?,
val maxAmount: BigDecimal?,
val providerIds: List<String>?,
val banner: MarketingBanner,
val targets: List<MarketingCampaignTarget>,
)

View file

@ -0,0 +1,14 @@
package com.tangem.domain.marketing.models
sealed interface MarketingCampaignTarget {
/**
* token_details / staking / yield campaigns target a network + contract address.
* [contractAddress] is `null` for native coins (the backend omits it), so a `null`/blank contract
* matches the coin of that network.
*/
data class NetworkContract(val networkId: String, val contractAddress: String?) : MarketingCampaignTarget
/** markets_token campaigns target a CoinGecko token id. */
data class CoingeckoId(val id: String) : MarketingCampaignTarget
}

View file

@ -0,0 +1,43 @@
package com.tangem.domain.marketing.models
/**
* Screen-specific request context. swap/onramp carry the pair params sent to the backend; background types
* carry the on-screen token identity used for client-side target matching (the request itself sends only [type]).
*/
sealed interface MarketingScreen {
val type: MarketingScreenType
data class Swap(
val fromNetwork: String,
val fromContractAddress: String,
val toNetwork: String,
val toContractAddress: String,
) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.SWAP
}
data class Onramp(
val fromFiat: String,
val toNetwork: String,
val toContractAddress: String,
) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.ONRAMP
}
data class TokenDetails(val networkId: String, val contractAddress: String) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.TOKEN_DETAILS
}
data class TokenMarkets(val coingeckoId: String) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.TOKEN_MARKETS
}
data class Staking(val networkId: String, val contractAddress: String) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.STAKING
}
data class Yield(val networkId: String, val contractAddress: String) : MarketingScreen {
override val type: MarketingScreenType = MarketingScreenType.YIELD
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.marketing.models
enum class MarketingScreenType(val value: String) {
SWAP("swap"),
ONRAMP("onramp"),
TOKEN_DETAILS("token_details"),
TOKEN_MARKETS("markets_token"),
STAKING("staking"),
YIELD("yield"),
;
/** Background types are ETag-cached; swap/onramp are always re-requested per pair selection. */
val isCacheable: Boolean
get() = this != SWAP && this != ONRAMP
companion object {
fun fromValue(value: String): MarketingScreenType? = entries.firstOrNull { it.value == value }
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.domain.marketing.models
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class MarketingScreenTypeTest {
@Test
fun `GIVEN screen types WHEN read value THEN matches backend snake_case contract`() {
// Assert
assertThat(MarketingScreenType.SWAP.value).isEqualTo("swap")
assertThat(MarketingScreenType.ONRAMP.value).isEqualTo("onramp")
assertThat(MarketingScreenType.TOKEN_DETAILS.value).isEqualTo("token_details")
assertThat(MarketingScreenType.TOKEN_MARKETS.value).isEqualTo("markets_token")
assertThat(MarketingScreenType.STAKING.value).isEqualTo("staking")
assertThat(MarketingScreenType.YIELD.value).isEqualTo("yield")
}
@Test
fun `GIVEN known value WHEN fromValue THEN returns type ELSE null`() {
// Assert
assertThat(MarketingScreenType.fromValue("token_details")).isEqualTo(MarketingScreenType.TOKEN_DETAILS)
assertThat(MarketingScreenType.fromValue("unknown")).isNull()
}
@Test
fun `GIVEN screen type WHEN isCacheable THEN only background types cached`() {
// Assert
assertThat(MarketingScreenType.SWAP.isCacheable).isFalse()
assertThat(MarketingScreenType.ONRAMP.isCacheable).isFalse()
assertThat(MarketingScreenType.TOKEN_DETAILS.isCacheable).isTrue()
assertThat(MarketingScreenType.TOKEN_MARKETS.isCacheable).isTrue()
assertThat(MarketingScreenType.STAKING.isCacheable).isTrue()
assertThat(MarketingScreenType.YIELD.isCacheable).isTrue()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.marketing
import arrow.core.Either
class DismissMarketingBannerUseCase(
private val repository: MarketingRepository,
) {
suspend operator fun invoke(campaignId: Int): Either<Throwable, Unit> = Either.catch {
repository.dismissBanner(campaignId)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.domain.marketing
import arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import java.math.BigDecimal
class GetMarketingBannerUseCase(
private val repository: MarketingRepository,
private val featureToggles: MarketingFeatureToggles,
) {
/**
* Returns campaigns for [screen], filtered (dismissed, target match, USD amount range) and sorted by priority.
*
* @param amountUsd USD equivalent of the entered amount (swap/onramp only). When null, the amount filter is skipped.
*/
suspend operator fun invoke(
screen: MarketingScreen,
amountUsd: BigDecimal? = null,
): Either<Throwable, List<MarketingCampaign>> {
if (!featureToggles.isMarketingBannersEnabled) return Either.Right(emptyList())
return repository.getCampaigns(screen).map { campaigns ->
val dismissed = repository.getDismissedBannerIds()
campaigns.asSequence()
.filterNot { it.id in dismissed }
.filter { matchesTarget(it, screen) }
.filter { matchesAmount(it, amountUsd) }
.sortedBy { it.priority }
.toList()
}
}
private fun matchesTarget(campaign: MarketingCampaign, screen: MarketingScreen): Boolean = when (screen) {
is MarketingScreen.Swap, is MarketingScreen.Onramp -> true // matched server-side by pair params
is MarketingScreen.TokenDetails -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress)
is MarketingScreen.Staking -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress)
is MarketingScreen.Yield -> matchesNetworkContract(campaign, screen.networkId, screen.contractAddress)
is MarketingScreen.TokenMarkets -> campaign.targets.any { target ->
target is MarketingCampaignTarget.CoingeckoId && target.id == screen.coingeckoId
}
}
private fun matchesNetworkContract(
campaign: MarketingCampaign,
networkId: String,
contractAddress: String,
): Boolean {
return campaign.targets.any { target ->
target is MarketingCampaignTarget.NetworkContract &&
target.networkId == networkId &&
contractAddressMatches(target = target.contractAddress, screen = contractAddress)
}
}
/**
* Native coins have no contract address: the backend sends `contractAddress: null` and the screen
* passes an empty string, so blank/null on both sides is a native-coin match. Otherwise the
* contracts must match case-insensitively.
*/
private fun contractAddressMatches(target: String?, screen: String): Boolean {
val normalizedTarget = target?.takeIf { it.isNotBlank() }
val normalizedScreen = screen.takeIf { it.isNotBlank() }
return when {
normalizedTarget == null && normalizedScreen == null -> true
normalizedTarget != null && normalizedScreen != null ->
normalizedTarget.equals(normalizedScreen, ignoreCase = true)
else -> false
}
}
private fun matchesAmount(campaign: MarketingCampaign, amountUsd: BigDecimal?): Boolean {
val isAmountScreen = campaign.type == MarketingScreenType.SWAP || campaign.type == MarketingScreenType.ONRAMP
if (!isAmountScreen || amountUsd == null) return true
val min = campaign.minAmount
val max = campaign.maxAmount
if (min != null && amountUsd < min) return false
if (max != null && amountUsd > max) return false
return true
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.marketing
interface MarketingFeatureToggles {
val isMarketingBannersEnabled: Boolean
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.marketing
import arrow.core.Either
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingScreen
interface MarketingRepository {
/** Fetches campaigns for [screen]. Returns Right(emptyList()) when there is nothing to show (incl. 5xx without cache). */
suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>>
/** Ids of campaigns whose banner the user has dismissed (stored client-side). */
suspend fun getDismissedBannerIds(): Set<Int>
suspend fun dismissBanner(campaignId: Int)
}

View file

@ -0,0 +1,34 @@
package com.tangem.domain.marketing
import com.google.common.truth.Truth.assertThat
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DismissMarketingBannerUseCaseTest {
private val repository: MarketingRepository = mockk()
private val useCase = DismissMarketingBannerUseCase(repository)
@BeforeEach
fun reset() = clearMocks(repository)
@Test
fun `GIVEN campaign id WHEN invoke THEN repository dismiss called and Right returned`() = runTest {
// Arrange
coEvery { repository.dismissBanner(7) } returns Unit
// Act
val result = useCase(7)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { repository.dismissBanner(7) }
}
}

View file

@ -0,0 +1,291 @@
package com.tangem.domain.marketing
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.marketing.models.MarketingBanner
import com.tangem.domain.marketing.models.MarketingCampaign
import com.tangem.domain.marketing.models.MarketingCampaignTarget
import com.tangem.domain.marketing.models.MarketingScreen
import com.tangem.domain.marketing.models.MarketingScreenType
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetMarketingBannerUseCaseTest {
private val repository: MarketingRepository = mockk()
private val featureToggles: MarketingFeatureToggles = mockk()
private val useCase = GetMarketingBannerUseCase(repository, featureToggles)
@BeforeEach
fun reset() {
clearMocks(repository, featureToggles)
every { featureToggles.isMarketingBannersEnabled } returns true
coEvery { repository.getDismissedBannerIds() } returns emptySet()
}
private fun banner() = MarketingBanner(
uiType = MarketingBanner.UiType.STANDALONE, text = null, iconUrl = null,
iconAlign = null, bgColor = null, deeplink = null, isDismissible = true,
)
private fun campaign(
id: Int,
type: MarketingScreenType,
priority: Int,
minAmount: BigDecimal? = null,
maxAmount: BigDecimal? = null,
targets: List<MarketingCampaignTarget> = emptyList(),
) = MarketingCampaign(
id = id,
type = type,
priority = priority,
startAt = null,
endAt = null,
minAmount = minAmount,
maxAmount = maxAmount,
providerIds = null,
banner = banner(),
targets = targets,
)
private val swapScreen = MarketingScreen.Swap("eth", "0xF", "btc", "0xT")
private val tokenScreen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0b8")
private val stakingScreen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0xA0b8")
private val yieldScreen = MarketingScreen.Yield(networkId = "ethereum", contractAddress = "0xA0b8")
@Test
fun `GIVEN toggle disabled WHEN invoke THEN empty without touching repository`() = runTest {
// Arrange
every { featureToggles.isMarketingBannersEnabled } returns false
// Act
val result = useCase(swapScreen)
// Assert
assertThat(result.getOrNull()).isEmpty()
coVerify(exactly = 0) { repository.getCampaigns(any()) }
coVerify(exactly = 0) { repository.getDismissedBannerIds() }
}
@Test
fun `GIVEN several campaigns WHEN invoke THEN sorted by priority ascending`() = runTest {
// Arrange
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 3),
campaign(id = 2, type = MarketingScreenType.SWAP, priority = 1),
campaign(id = 3, type = MarketingScreenType.SWAP, priority = 2),
).right()
// Act
val result = useCase(swapScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(2, 3, 1).inOrder()
}
@Test
fun `GIVEN dismissed id WHEN invoke THEN dismissed campaign filtered out`() = runTest {
// Arrange
coEvery { repository.getDismissedBannerIds() } returns setOf(2)
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1),
campaign(id = 2, type = MarketingScreenType.SWAP, priority = 2),
).right()
// Act
val result = useCase(swapScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN amount below min WHEN invoke swap THEN campaign filtered out`() = runTest {
// Arrange
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)),
).right()
// Act
val result = useCase(swapScreen, amountUsd = BigDecimal(25))
// Assert
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `GIVEN amount within range WHEN invoke swap THEN campaign kept`() = runTest {
// Arrange
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)),
).right()
// Act
val result = useCase(swapScreen, amountUsd = BigDecimal(100))
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN amount above max WHEN invoke swap THEN campaign filtered out`() = runTest {
// Arrange
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50), maxAmount = BigDecimal(300)),
).right()
// Act
val result = useCase(swapScreen, amountUsd = BigDecimal(500))
// Assert
assertThat(result.getOrNull()).isEmpty()
}
@Test
fun `GIVEN null amount WHEN invoke swap THEN amount filter skipped`() = runTest {
// Arrange
coEvery { repository.getCampaigns(swapScreen) } returns listOf(
campaign(id = 1, type = MarketingScreenType.SWAP, priority = 1, minAmount = BigDecimal(50)),
).right()
// Act
val result = useCase(swapScreen, amountUsd = null)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN background type WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest {
// Arrange
coEvery { repository.getCampaigns(tokenScreen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1,
targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")),
),
campaign(
id = 2, type = MarketingScreenType.TOKEN_DETAILS, priority = 2,
targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")),
),
).right()
// Act
val result = useCase(tokenScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN token markets screen WHEN invoke THEN only campaigns matching the coingecko id kept`() = runTest {
// Arrange
val marketsScreen = MarketingScreen.TokenMarkets(coingeckoId = "1696501400")
coEvery { repository.getCampaigns(marketsScreen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.TOKEN_MARKETS, priority = 1,
targets = listOf(MarketingCampaignTarget.CoingeckoId("1696501400")),
),
campaign(
id = 2, type = MarketingScreenType.TOKEN_MARKETS, priority = 2,
targets = listOf(MarketingCampaignTarget.CoingeckoId("other")),
),
).right()
// Act
val result = useCase(marketsScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN staking screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest {
// Arrange
coEvery { repository.getCampaigns(stakingScreen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.STAKING, priority = 1,
targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")),
),
campaign(
id = 2, type = MarketingScreenType.STAKING, priority = 2,
targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")),
),
).right()
// Act
val result = useCase(stakingScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN yield screen WHEN invoke THEN only campaigns matching the on-screen token kept`() = runTest {
// Arrange
coEvery { repository.getCampaigns(yieldScreen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.YIELD, priority = 1,
targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xA0b8")),
),
campaign(
id = 2, type = MarketingScreenType.YIELD, priority = 2,
targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", "0xOther")),
),
).right()
// Act
val result = useCase(yieldScreen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN contract address differing only in case WHEN invoke THEN campaign matched`() = runTest {
// Arrange
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0xA0B8")
coEvery { repository.getCampaigns(screen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.TOKEN_DETAILS, priority = 1,
targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", "0xa0b8")),
),
).right()
// Act
val result = useCase(screen)
// Assert
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
@Test
fun `GIVEN native coin screen WHEN campaign targets that coin with null contract THEN matched`() = runTest {
// Arrange — native coin: screen contract is blank, target contract is null
val screen = MarketingScreen.Yield(networkId = "bitcoin", contractAddress = "")
coEvery { repository.getCampaigns(screen) } returns listOf(
campaign(
id = 1, type = MarketingScreenType.YIELD, priority = 1,
targets = listOf(MarketingCampaignTarget.NetworkContract("bitcoin", contractAddress = null)),
),
campaign(
id = 2, type = MarketingScreenType.YIELD, priority = 2,
targets = listOf(MarketingCampaignTarget.NetworkContract("ethereum", contractAddress = null)),
),
).right()
// Act
val result = useCase(screen)
// Assert — only the native coin of the on-screen network matches
assertThat(result.getOrNull()?.map { it.id }).containsExactly(1)
}
}