diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt index dd697566df..37135fb787 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt @@ -11,7 +11,6 @@ data class YieldBoostStatusResponse( @Json(name = "userAddress") val userAddress: String?, @Json(name = "contractAddress") val contractAddress: String?, @Json(name = "promoEnrollmentStatus") val promoEnrollmentStatus: String, - @Json(name = "activationDate") val activationDate: String?, @Json(name = "qualificationEndDate") val qualificationEndDate: String?, @Json(name = "disqualificationReason") val disqualificationReason: String?, ) \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt index 4ddbac4301..ef54706c37 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt @@ -16,43 +16,26 @@ internal object YieldBoostStatusConverter { private const val REASON_CLOSED = "closed" fun convert(dto: YieldBoostStatusResponse): YieldBoostStatus = when (dto.promoEnrollmentStatus.lowercase()) { - STATUS_ACTIVE -> dto.toActive() ?: YieldBoostStatus.NotStarted - STATUS_COMPLETED -> dto.toCompleted() ?: YieldBoostStatus.NotStarted + STATUS_ACTIVE, STATUS_COMPLETED -> dto.toEnrolled() STATUS_DISQUALIFIED -> YieldBoostStatus.Disqualified(reason = dto.disqualificationReason.toReason()) STATUS_NOT_STARTED -> YieldBoostStatus.NotStarted else -> YieldBoostStatus.NotStarted // forward-compat: unknown status → treat as NotStarted } - /** Backend `"active"` → [YieldBoostStatus.Active]. Returns `null` if mandatory dates can't be parsed. */ - private fun YieldBoostStatusResponse.toActive(): YieldBoostStatus.Active? { - val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null - val qualificationEnd = - qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null - return YieldBoostStatus.Active( - tokenName = tokenName.orEmpty(), - networkId = networkId.orEmpty(), - moduleAddress = moduleAddress.orEmpty(), - userAddress = userAddress.orEmpty(), - contractAddress = contractAddress.orEmpty(), - activationDate = activation, - qualificationEndDate = qualificationEnd, - ) - } - - private fun YieldBoostStatusResponse.toCompleted(): YieldBoostStatus.Completed? { - val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null - val qualificationEnd = - qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null - return YieldBoostStatus.Completed( - tokenName = tokenName.orEmpty(), - networkId = networkId.orEmpty(), - moduleAddress = moduleAddress.orEmpty(), - userAddress = userAddress.orEmpty(), - contractAddress = contractAddress.orEmpty(), - activationDate = activation, - qualificationEndDate = qualificationEnd, - ) - } + /** + * Backend `"active"` / `"completed"` → [YieldBoostStatus.Enrolled]. + * + * An unparseable / missing `qualificationEndDate` is kept as `null` (block hidden) — never downgraded to + * [YieldBoostStatus.NotStarted], which would re-prompt an already-enrolled user to join. + */ + private fun YieldBoostStatusResponse.toEnrolled(): YieldBoostStatus.Enrolled = YieldBoostStatus.Enrolled( + tokenName = tokenName.orEmpty(), + networkId = networkId.orEmpty(), + moduleAddress = moduleAddress.orEmpty(), + userAddress = userAddress.orEmpty(), + contractAddress = contractAddress.orEmpty(), + qualificationEndDate = qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() }, + ) private fun String?.toReason(): YieldBoostStatus.Disqualified.Reason = when (this?.lowercase()) { REASON_FROD -> YieldBoostStatus.Disqualified.Reason.FROD diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt index 3e9c7c3850..0741d4ae6c 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt @@ -3,11 +3,11 @@ package com.tangem.data.yield.supply.promo.converter import com.google.common.truth.Truth.assertThat import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.domain.yield.supply.models.YieldBoostStatus +import kotlinx.datetime.Instant import org.junit.jupiter.api.Test class YieldBoostStatusConverterTest { - private val activation = "2026-05-01T00:00:00Z" private val qualificationEnd = "2026-06-01T00:00:00Z" @Test @@ -20,7 +20,7 @@ class YieldBoostStatusConverterTest { } @Test - fun `GIVEN active backend status with valid dates WHEN convert THEN returns Active`() { + fun `GIVEN active backend status with valid date WHEN convert THEN returns Enrolled`() { val dto = dto( promoEnrollmentStatus = "active", tokenName = "USD Coin", @@ -28,47 +28,49 @@ class YieldBoostStatusConverterTest { moduleAddress = "0xmodule", userAddress = "0xuser", contractAddress = "0xcontract", - activationDate = activation, qualificationEndDate = qualificationEnd, ) val result = YieldBoostStatusConverter.convert(dto) - assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java) - val active = result as YieldBoostStatus.Active - assertThat(active.tokenName).isEqualTo("USD Coin") - assertThat(active.networkId).isEqualTo("ethereum") - assertThat(active.contractAddress).isEqualTo("0xcontract") + assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java) + val enrolled = result as YieldBoostStatus.Enrolled + assertThat(enrolled.tokenName).isEqualTo("USD Coin") + assertThat(enrolled.networkId).isEqualTo("ethereum") + assertThat(enrolled.contractAddress).isEqualTo("0xcontract") + assertThat(enrolled.qualificationEndDate).isEqualTo(Instant.parse(qualificationEnd)) } @Test - fun `GIVEN active status missing activationDate WHEN convert THEN falls back to NotStarted`() { + fun `GIVEN active status missing qualificationEndDate WHEN convert THEN returns Enrolled with null date`() { val dto = dto( promoEnrollmentStatus = "active", - activationDate = null, - qualificationEndDate = qualificationEnd, + contractAddress = "0xcontract", + qualificationEndDate = null, ) val result = YieldBoostStatusConverter.convert(dto) - assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java) + assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull() } @Test - fun `GIVEN active status with malformed activationDate WHEN convert THEN falls back to NotStarted`() { + fun `GIVEN active status with malformed qualificationEndDate WHEN convert THEN returns Enrolled with null date`() { val dto = dto( promoEnrollmentStatus = "active", - activationDate = "not-an-iso", - qualificationEndDate = qualificationEnd, + contractAddress = "0xcontract", + qualificationEndDate = "not-an-iso", ) val result = YieldBoostStatusConverter.convert(dto) - assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java) + assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull() } @Test - fun `GIVEN completed status with valid dates WHEN convert THEN returns Completed`() { + fun `GIVEN completed status with valid date WHEN convert THEN returns Enrolled`() { val dto = dto( promoEnrollmentStatus = "completed", tokenName = "USDT", @@ -76,13 +78,14 @@ class YieldBoostStatusConverterTest { moduleAddress = "0xmodule", userAddress = "0xuser", contractAddress = "0xcontract", - activationDate = "2026-04-01T00:00:00Z", qualificationEndDate = "2026-05-01T00:00:00Z", ) val result = YieldBoostStatusConverter.convert(dto) - assertThat(result).isInstanceOf(YieldBoostStatus.Completed::class.java) + assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java) + assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate) + .isEqualTo(Instant.parse("2026-05-01T00:00:00Z")) } @Test @@ -153,13 +156,12 @@ class YieldBoostStatusConverterTest { moduleAddress = "0xmodule", userAddress = "0xuser", contractAddress = "0xcontract", - activationDate = activation, qualificationEndDate = qualificationEnd, ) val result = YieldBoostStatusConverter.convert(dto) - assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java) + assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java) } private fun dto( @@ -169,7 +171,6 @@ class YieldBoostStatusConverterTest { moduleAddress: String? = null, userAddress: String? = null, contractAddress: String? = null, - activationDate: String? = null, qualificationEndDate: String? = null, disqualificationReason: String? = null, ) = YieldBoostStatusResponse( @@ -179,7 +180,6 @@ class YieldBoostStatusConverterTest { userAddress = userAddress, contractAddress = contractAddress, promoEnrollmentStatus = promoEnrollmentStatus, - activationDate = activationDate, qualificationEndDate = qualificationEndDate, disqualificationReason = disqualificationReason, ) diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt index 383e27cef2..640702d5cf 100644 --- a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt @@ -6,26 +6,22 @@ sealed interface YieldBoostStatus { data object NotStarted : YieldBoostStatus - /** User entered boost, qualification period is still running. */ - data class Active( + /** + * User is enrolled in the boost (backend `active` or `completed`). + * + * The boost block on the active screen is driven entirely by [qualificationEndDate], which the backend + * computes as the end of the bonus-accrual period: + * - `null` — nothing is shown; + * - in the future — days left until the date; + * - reached / passed — awaiting payout. + */ + data class Enrolled( val tokenName: String, val networkId: String, val moduleAddress: String, val userAddress: String, val contractAddress: String, - val activationDate: Instant, - val qualificationEndDate: Instant, - ) : YieldBoostStatus - - /** Boost has finished (backend `completed`). */ - data class Completed( - val tokenName: String, - val networkId: String, - val moduleAddress: String, - val userAddress: String, - val contractAddress: String, - val activationDate: Instant, - val qualificationEndDate: Instant, + val qualificationEndDate: Instant?, ) : YieldBoostStatus data class Disqualified(val reason: Reason) : YieldBoostStatus { diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt index 9ba69c2fea..9afbbb6492 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt @@ -91,21 +91,10 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest { } @Test - fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest { + fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest { val token = createToken() coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() - coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus() - - val result = useCase(userWalletId, token) - - assertThat(result.getOrNull()).isFalse() - } - - @Test - fun `GIVEN status is Completed WHEN invoke THEN returns Right(false)`() = runTest { - val token = createToken() - coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() - coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns completedStatus() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus() val result = useCase(userWalletId, token) @@ -162,26 +151,15 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest { link = null, ) - private fun activeStatus() = YieldBoostStatus.Active( + private fun enrolledStatus() = YieldBoostStatus.Enrolled( tokenName = "USD Coin", networkId = networkRawId, moduleAddress = "0xmodule", userAddress = "0xuser", contractAddress = contractAddress, - activationDate = Instant.parse("2026-05-01T00:00:00Z"), qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"), ) - private fun completedStatus() = YieldBoostStatus.Completed( - tokenName = "USD Coin", - networkId = networkRawId, - moduleAddress = "0xmodule", - userAddress = "0xuser", - contractAddress = contractAddress, - activationDate = Instant.parse("2026-04-01T00:00:00Z"), - qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"), - ) - private fun createToken( contractAddress: String = this.contractAddress, networkRawId: String = this.networkRawId, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt index 9461882859..ae20808d28 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt @@ -57,9 +57,9 @@ class ShouldShowYieldBoostMainBannerUseCaseTest { } @Test - fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest { + fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest { coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() - coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus() val result = useCase(userWalletId) @@ -92,13 +92,12 @@ class ShouldShowYieldBoostMainBannerUseCaseTest { link = null, ) - private fun activeStatus() = YieldBoostStatus.Active( + private fun enrolledStatus() = YieldBoostStatus.Enrolled( tokenName = "USD Coin", networkId = networkRawId, moduleAddress = "0xmodule", userAddress = "0xuser", contractAddress = contractAddress, - activationDate = Instant.parse("2026-05-01T00:00:00Z"), qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"), ) } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt new file mode 100644 index 0000000000..1eda6da853 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt @@ -0,0 +1,22 @@ +package com.tangem.features.yield.supply.impl.active.model + +import kotlinx.datetime.Instant + +/** What the boost block on the active screen should display, derived solely from the qualification end date. */ +internal sealed interface BoostBlockState { + + /** Qualification period is still running — show the countdown. */ + data class DaysLeft(val days: Int) : BoostBlockState + + /** Qualification period is over — show the awaiting-payout copy. */ + data object AwaitingPayout : BoostBlockState + + /** No qualification end date — show nothing. */ + data object Hidden : BoostBlockState +} + +internal fun resolveBoostBlockState(qualificationEndDate: Instant?, now: Instant): BoostBlockState = when { + qualificationEndDate == null -> BoostBlockState.Hidden + now >= qualificationEndDate -> BoostBlockState.AwaitingPayout + else -> BoostBlockState.DaysLeft(days = (qualificationEndDate - now).inWholeDays.toInt()) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 9afb505aef..2bc86a016d 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -54,8 +54,6 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.datetime.Clock import javax.inject.Inject -import kotlin.math.max -import kotlin.time.Duration.Companion.milliseconds @Suppress("LongParameterList", "LargeClass") @ModelScoped @@ -245,21 +243,18 @@ internal class YieldSupplyActiveModel @Inject constructor( modelScope.launch(dispatchers.io) { val status = getYieldBoostStatusUseCase(userWalletId).getOrNull() ?: return@launch val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch - when { - status is YieldBoostStatus.Active && status.matches(token) -> { - uiState.update { - it.copy(boostText = buildActiveBoostText(status), onBoostClick = ::onBoostClick) - } - } - status is YieldBoostStatus.Completed && status.matches(token) -> { - uiState.update { - it.copy( - boostText = resourceReference(CoreResR.string.yield_promo_completed), - onBoostClick = ::onBoostClick, - ) - } - } + if (status !is YieldBoostStatus.Enrolled || !status.matches(token)) return@launch + + val state = resolveBoostBlockState( + qualificationEndDate = status.qualificationEndDate, + now = Clock.System.now(), + ) + val boostText = when (state) { + is BoostBlockState.DaysLeft -> buildDaysLeftText(state.days) + BoostBlockState.AwaitingPayout -> resourceReference(CoreResR.string.yield_promo_completed) + BoostBlockState.Hidden -> return@launch } + uiState.update { it.copy(boostText = boostText, onBoostClick = ::onBoostClick) } } } @@ -274,32 +269,17 @@ internal class YieldSupplyActiveModel @Inject constructor( ) } - private fun buildActiveBoostText(status: YieldBoostStatus.Active): TextReference { - val daysLeft = computeDaysLeft(status.qualificationEndDate.toEpochMilliseconds()) - return combinedReference( - pluralReference( - id = CoreResR.plurals.common_days, - count = daysLeft, - formatArgs = wrappedList(daysLeft), - ), - stringReference(" "), - resourceReference(CoreResR.string.yield_promo_left_title), - ) - } + private fun buildDaysLeftText(daysLeft: Int): TextReference = combinedReference( + pluralReference( + id = CoreResR.plurals.common_days, + count = daysLeft, + formatArgs = wrappedList(daysLeft), + ), + stringReference(" "), + resourceReference(CoreResR.string.yield_promo_left_title), + ) - private fun computeDaysLeft(qualificationEndEpochMillis: Long): Int { - val nowMillis = Clock.System.now().toEpochMilliseconds() - val deltaMillis = max(qualificationEndEpochMillis - nowMillis, 0L) - return deltaMillis.milliseconds.inWholeDays.toInt() - } - - private fun YieldBoostStatus.Active.matches(token: CryptoCurrency.Token): Boolean = - matchesToken(contractAddress = contractAddress, networkId = networkId, token = token) - - private fun YieldBoostStatus.Completed.matches(token: CryptoCurrency.Token): Boolean = - matchesToken(contractAddress = contractAddress, networkId = networkId, token = token) - - private fun matchesToken(contractAddress: String, networkId: String, token: CryptoCurrency.Token): Boolean { + private fun YieldBoostStatus.Enrolled.matches(token: CryptoCurrency.Token): Boolean { val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId) return contractAddress.equals(token.contractAddress, ignoreCase = shouldIgnoreCase) && networkId == token.network.rawId diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt new file mode 100644 index 0000000000..9d2dd5f854 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt @@ -0,0 +1,54 @@ +package com.tangem.features.yield.supply.impl.active.model + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test + +internal class BoostBlockStateTest { + + private val now = Instant.parse("2026-05-28T00:00:00Z") + + @Test + fun `GIVEN null qualificationEndDate WHEN resolve THEN Hidden`() { + val result = resolveBoostBlockState(qualificationEndDate = null, now = now) + + assertThat(result).isEqualTo(BoostBlockState.Hidden) + } + + @Test + fun `GIVEN future qualificationEndDate WHEN resolve THEN DaysLeft with whole days`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 4)) + } + + @Test + fun `GIVEN qualificationEndDate less than a day away WHEN resolve THEN DaysLeft zero`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-05-28T18:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.DaysLeft(days = 0)) + } + + @Test + fun `GIVEN qualificationEndDate equal to now WHEN resolve THEN AwaitingPayout`() { + val result = resolveBoostBlockState(qualificationEndDate = now, now = now) + + assertThat(result).isEqualTo(BoostBlockState.AwaitingPayout) + } + + @Test + fun `GIVEN past qualificationEndDate WHEN resolve THEN AwaitingPayout`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.AwaitingPayout) + } +} \ No newline at end of file