Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 15:07:42 +03:00
parent 233d76d9f4
commit 8b49dc2f7f
17 changed files with 694 additions and 0 deletions

View file

@ -0,0 +1,16 @@
package com.tangem.features.tangempay.cashback.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
/**
* Public contract of the Cashback screen. Kept in an `api` sub-package (separate from `impl`) so it
* can be lifted into a dedicated module without refactoring.
*/
internal interface TangemPayCashbackComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId)
interface Factory : ComponentFactory<Params, TangemPayCashbackComponent>
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.tangempay.cashback.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackModel
import com.tangem.features.tangempay.cashback.impl.ui.TangemPayCashbackScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultTangemPayCashbackComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: TangemPayCashbackComponent.Params,
) : TangemPayCashbackComponent, AppComponentContext by appComponentContext {
private val model: TangemPayCashbackModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
TangemPayCashbackScreen(state = state, modifier = modifier)
}
@AssistedFactory
interface Factory : TangemPayCashbackComponent.Factory {
override fun create(
context: AppComponentContext,
params: TangemPayCashbackComponent.Params,
): DefaultTangemPayCashbackComponent
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.tangempay.cashback.impl.model
import com.tangem.core.ui.utils.DateTimeFormatters
import org.joda.time.DateTime
internal class TangemPayCashbackDateFormatter {
fun formatMonth(year: Int, month: Int): String =
DateTimeFormatters.formatDate(DateTime(year, month, 1, 0, 0), DateTimeFormatters.dateMMMM)
fun formatMonthDay(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateMMMMd)
fun formatWindow(start: DateTime, end: DateTime): String {
val isSameMonth = start.year == end.year && start.monthOfYear == end.monthOfYear
return if (isSameMonth) {
"${formatMonthDay(start)}${end.dayOfMonth}"
} else {
"${formatMonthDay(start)} ${formatMonthDay(end)}"
}
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.tangempay.cashback.impl.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.joda.time.DateTime
import java.math.BigDecimal
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayCashbackModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
) : Model() {
private val cashbackConverter = TangemPayCashbackUmConverter(onCloseClick = router::pop)
val uiState: StateFlow<TangemPayCashbackUM>
field = MutableStateFlow(cashbackConverter.convert(STUB_CASHBACK))
private companion object {
// TODO([REDACTED_TASK_KEY]): replace stub with repository load
val STUB_CASHBACK = TangemPayCashback(
confirmedAmount = BigDecimal("22.54"),
currency = "USD",
period = TangemPayCashback.Period(
year = 2026,
month = 6,
payoutStart = DateTime.parse("2026-07-01"),
payoutEnd = DateTime.parse("2026-07-05"),
),
)
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.tangempay.cashback.impl.model
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.format.bigdecimal.optionalDecimals
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM
import com.tangem.utils.converter.Converter
internal class TangemPayCashbackUmConverter(
private val onCloseClick: () -> Unit,
private val dateFormatter: TangemPayCashbackDateFormatter = TangemPayCashbackDateFormatter(),
) : Converter<TangemPayCashback?, TangemPayCashbackUM> {
// TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources
override fun convert(value: TangemPayCashback?): TangemPayCashbackUM {
if (value == null || value.confirmedAmount.signum() == 0) {
return TangemPayCashbackUM(
title = stringReference("Start spending and earn cashback"),
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = onCloseClick,
)
}
val currency = getJavaCurrencyByCode(value.currency)
val earned = value.confirmedAmount.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }
val month = dateFormatter.formatMonth(value.period.year, value.period.month)
val payoutWindow = dateFormatter.formatWindow(value.period.payoutStart, value.period.payoutEnd)
val payoutEnd = dateFormatter.formatMonthDay(value.period.payoutEnd)
val banner = if (value.confirmedAmount.signum() < 0) {
TangemPayCashbackUM.Banner(
text = stringReference(
"We received a refund for a purchase for which cashback had previously been awarded",
),
type = TangemPayCashbackUM.Banner.Type.Error,
)
} else {
TangemPayCashbackUM.Banner(
text = stringReference("Cashback $earned for $month will be deposited till $payoutEnd"),
type = TangemPayCashbackUM.Banner.Type.Info,
)
}
return TangemPayCashbackUM(
title = stringReference("$earned earned in $month"),
subtitle = stringReference("Will be deposited on $payoutWindow"),
isEmpty = false,
banner = banner,
onCloseClick = onCloseClick,
)
}
}

View file

@ -0,0 +1,217 @@
package com.tangem.features.tangempay.cashback.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM
import com.tangem.features.tangempay.details.impl.R
import com.tangem.core.ui.R as CoreUiR
private const val GLOW_RADIUS_FACTOR = 0.585f
private const val GLOW_BLUE_ALPHA = 0.20f
private const val GLOW_WARM_ALPHA = 0.15f
@Composable
internal fun TangemPayCashbackScreen(state: TangemPayCashbackUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary),
) {
if (state.isEmpty) {
EmptyStateGlow(modifier = Modifier.fillMaxSize())
}
Column(modifier = Modifier.fillMaxSize()) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
// TODO([REDACTED_TASK_KEY]): move to string resources
title = stringReference("Cashback"),
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
HeroBlock(state = state)
state.banner?.let { banner ->
CashbackBanner(
banner = banner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
private fun EmptyStateGlow(modifier: Modifier = Modifier) {
val isDark = LocalIsInDarkTheme.current
val warm = if (isDark) Color(0xFF7A4A25) else Color(0xFFEA8C44)
val blue = if (isDark) Color(0xFF0090F9) else Color(0xFF0092FC)
Box(
modifier = modifier
.blur(56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.drawBehind {
val radius = size.width * GLOW_RADIUS_FACTOR
val center = Offset(x = size.width / 2f, y = 0f)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.Transparent,
warm.copy(alpha = GLOW_WARM_ALPHA),
blue.copy(alpha = GLOW_BLUE_ALPHA),
),
center = center,
radius = radius,
),
radius = radius,
center = center,
)
},
)
}
@Composable
private fun HeroBlock(state: TangemPayCashbackUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography3.heading.medium,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = state.subtitle.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun CashbackBanner(banner: TangemPayCashbackUM.Banner, modifier: Modifier = Modifier) {
val contentColor = when (banner.type) {
TangemPayCashbackUM.Banner.Type.Info -> TangemTheme.colors3.text.status.info
TangemPayCashbackUM.Banner.Type.Error -> TangemTheme.colors3.text.status.error
}
val backgroundColor = when (banner.type) {
TangemPayCashbackUM.Banner.Type.Info -> TangemTheme.colors3.bg.status.infoSubtle
TangemPayCashbackUM.Banner.Type.Error -> TangemTheme.colors3.bg.status.errorSubtle
}
Row(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(backgroundColor)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(CoreUiR.drawable.ic_information_24),
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Text(
text = banner.text.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = contentColor,
)
}
}
@Preview(device = Devices.PIXEL_7_PRO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayCashbackScreenPreview(
@PreviewParameter(TangemPayCashbackUMProvider::class) state: TangemPayCashbackUM,
) {
TangemThemePreviewRedesign {
TangemPayCashbackScreen(state = state)
}
}
private class TangemPayCashbackUMProvider : CollectionPreviewParameterProvider<TangemPayCashbackUM>(
collection = listOf(
TangemPayCashbackUM(
title = stringReference("$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 15"),
isEmpty = false,
banner = TangemPayCashbackUM.Banner(
text = stringReference("Cashback $22.54 for June will be deposited till July 5"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = {},
),
TangemPayCashbackUM(
title = stringReference("$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 15"),
isEmpty = false,
banner = TangemPayCashbackUM.Banner(
text = stringReference(
"We received a refund for a purchase for which cashback had previously been awarded",
),
type = TangemPayCashbackUM.Banner.Type.Error,
),
onCloseClick = {},
),
TangemPayCashbackUM(
title = stringReference("Start spending and earn cashback"),
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = {},
),
),
)

View file

@ -0,0 +1,22 @@
package com.tangem.features.tangempay.cashback.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class TangemPayCashbackUM(
val title: TextReference,
val subtitle: TextReference,
val isEmpty: Boolean,
val banner: Banner?,
val onCloseClick: () -> Unit,
) {
@Immutable
data class Banner(
val text: TextReference,
val type: Type,
) {
enum class Type { Info, Error }
}
}

View file

@ -16,6 +16,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent
import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanComponent
@ -36,6 +37,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory,
private val cashbackComponentFactory: TangemPayCashbackComponent.Factory,
) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent {
private val stackNavigation = StackNavigation<TangemPayAccountDetailsInnerRoute>()
@ -107,6 +109,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
TangemPayVirtualAccountDepositSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
TangemPayAccountDetailsInnerRoute.Cashback -> cashbackComponentFactory.create(
context = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCashbackComponent.Params(
userWalletId = params.initialStatus.userWalletId,
),
)
}
private fun onChildBack() {

View file

@ -1,5 +1,7 @@
package com.tangem.features.tangempay.di
import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.features.tangempay.cashback.impl.DefaultTangemPayCashbackComponent
import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent
@ -31,4 +33,10 @@ internal interface TangemPayDetailsFeatureModule {
fun bindTangemPayTransactionBottomSheetComponentFactory(
factory: TangemPayTxHistoryDetailsComponent.Factory,
): TangemPayTransactionBottomSheetComponent.Factory
@Binds
@Singleton
fun bindTangemPayCashbackComponentFactory(
factory: DefaultTangemPayCashbackComponent.Factory,
): TangemPayCashbackComponent.Factory
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackModel
import com.tangem.features.tangempay.closure.TangemPayCloseCardModel
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel
import com.tangem.features.tangempay.model.*
@ -96,4 +97,9 @@ internal interface TangemPayModelModule {
@IntoMap
@ClassKey(TangemPaySelectPlanModel::class)
fun bindTangemPaySelectPlanModel(model: TangemPaySelectPlanModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCashbackModel::class)
fun bindTangemPayCashbackModel(model: TangemPayCashbackModel): Model
}

View file

@ -427,6 +427,10 @@ internal class TangemPayDetailsModel @Inject constructor(
router.push(TangemPayAccountDetailsInnerRoute.CurrentPlan(tariffPlan))
}
override fun onClickCashback() {
router.push(TangemPayAccountDetailsInnerRoute.Cashback)
}
override fun onCardClick(cardId: String) {
analytics.send(TangemPayAnalyticsEvents.CardIconClicked())
router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId))

View file

@ -28,4 +28,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
@Serializable
data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute()
@Serializable
data object Cashback : TangemPayAccountDetailsInnerRoute()
}

View file

@ -11,6 +11,7 @@ internal interface TangemPayDetailIntents {
fun onClickWithdraw()
fun onClickTermsAndLimits()
fun onClickVisaBenefits()
fun onClickCashback()
fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan)
fun onCancelPlusTransition(orderId: String)
fun onCardClick(cardId: String)

View file

@ -0,0 +1,70 @@
package com.tangem.features.tangempay.cashback.impl.model
import android.text.format.DateFormat
import com.google.common.truth.Truth.assertThat
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.joda.time.DateTime
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.util.Locale
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayCashbackDateFormatterTest {
private val defaultLocale = Locale.getDefault()
private val formatter = TangemPayCashbackDateFormatter()
@BeforeEach
fun setup() {
Locale.setDefault(Locale.US)
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
Locale.setDefault(defaultLocale)
}
@Test
fun `GIVEN year and month WHEN formatMonth THEN full month name`() {
// Act
val actual = formatter.formatMonth(year = 2026, month = 6)
// Assert
assertThat(actual).isEqualTo("June")
}
@Test
fun `GIVEN date WHEN formatMonthDay THEN month name then day`() {
// Act
val actual = formatter.formatMonthDay(DateTime.parse("2026-07-05"))
// Assert
assertThat(actual).isEqualTo("July 5")
}
@Test
fun `GIVEN window within one month WHEN formatWindow THEN single month with day range`() {
// Act
val actual = formatter.formatWindow(DateTime.parse("2026-07-01"), DateTime.parse("2026-07-05"))
// Assert
assertThat(actual).isEqualTo("July 15")
}
@Test
fun `GIVEN window spanning two months WHEN formatWindow THEN month name on both sides`() {
// Act
val actual = formatter.formatWindow(DateTime.parse("2026-07-30"), DateTime.parse("2026-08-02"))
// Assert
assertThat(actual).isEqualTo("July 30 August 2")
}
}

View file

@ -0,0 +1,152 @@
package com.tangem.features.tangempay.cashback.impl.model
import android.text.format.DateFormat
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.joda.time.DateTime
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
import java.util.Locale
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayCashbackUmConverterTest {
private val defaultLocale = Locale.getDefault()
private val onCloseClick: () -> Unit = {}
private val converter = TangemPayCashbackUmConverter(onCloseClick = onCloseClick)
@BeforeEach
fun setup() {
Locale.setDefault(Locale.US)
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
Locale.setDefault(defaultLocale)
}
@ParameterizedTest
@MethodSource("emptyStateCashback")
fun `GIVEN null or zero amount WHEN convert THEN empty state without banner`(cashback: TangemPayCashback?) {
// Act
val actual = converter.convert(cashback)
// Assert
val expected = TangemPayCashbackUM(
title = stringReference("Start spending and earn cashback"),
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `GIVEN positive amount WHEN convert THEN earned title and info deposit banner`() {
// Arrange
val cashback = createCashback(confirmedAmount = BigDecimal("22.54"))
// Act
val actual = converter.convert(cashback)
// Assert
val expected = TangemPayCashbackUM(
title = stringReference("$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 15"),
isEmpty = false,
banner = TangemPayCashbackUM.Banner(
text = stringReference("Cashback $22.54 for June will be deposited till July 5"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `GIVEN negative amount WHEN convert THEN earned title and refund error banner`() {
// Arrange
val cashback = createCashback(confirmedAmount = BigDecimal("-22.54"))
// Act
val actual = converter.convert(cashback)
// Assert
val expected = TangemPayCashbackUM(
title = stringReference("-$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 15"),
isEmpty = false,
banner = TangemPayCashbackUM.Banner(
text = stringReference(
"We received a refund for a purchase for which cashback had previously been awarded",
),
type = TangemPayCashbackUM.Banner.Type.Error,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `GIVEN payout window spanning two months WHEN convert THEN subtitle shows month on both sides`() {
// Arrange
val cashback = createCashback(
payoutStart = DateTime.parse("2026-07-30"),
payoutEnd = DateTime.parse("2026-08-02"),
)
// Act
val actual = converter.convert(cashback)
// Assert
val expected = TangemPayCashbackUM(
title = stringReference("$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 30 August 2"),
isEmpty = false,
banner = TangemPayCashbackUM.Banner(
text = stringReference("Cashback $22.54 for June will be deposited till August 2"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
private fun emptyStateCashback(): List<TangemPayCashback?> = listOf(
null,
createCashback(confirmedAmount = BigDecimal.ZERO),
)
private fun createCashback(
confirmedAmount: BigDecimal = BigDecimal("22.54"),
currency: String = "USD",
year: Int = 2026,
month: Int = 6,
payoutStart: DateTime = DateTime.parse("2026-07-01"),
payoutEnd: DateTime = DateTime.parse("2026-07-05"),
): TangemPayCashback = TangemPayCashback(
confirmedAmount = confirmedAmount,
currency = currency,
period = TangemPayCashback.Period(
year = year,
month = month,
payoutStart = payoutStart,
payoutEnd = payoutEnd,
),
)
}