Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 17:09:47 +03:00
parent b4d11538ea
commit 454c08b894
21 changed files with 982 additions and 112 deletions

View file

@ -32,8 +32,10 @@ data class CashbackPromotionsResponse(
@JsonClass(generateAdapter = true)
data class AdditionalCashback(
@Json(name = "promotion_id") val promotionId: String?,
@Json(name = "label") val label: String?,
@Json(name = "scope") val scope: String?,
@Json(name = "id") val id: String?,
@Json(name = "name") val name: String?,
@Json(name = "description") val description: String?,
@Json(name = "is_permanent") val isPermanent: Boolean?,
@Json(name = "end_date") val endDate: String?,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.CashbackPromotionsResponse
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
/** Maps [CashbackPromotionsResponse] (BFF) to the domain [CashbackPromotions]. */
internal object CashbackPromotionsConverter : Converter<CashbackPromotionsResponse, CashbackPromotions> {
@ -10,6 +11,7 @@ internal object CashbackPromotionsConverter : Converter<CashbackPromotionsRespon
override fun convert(value: CashbackPromotionsResponse): CashbackPromotions {
return CashbackPromotions(
cardTiers = value.cashbackOnCards?.tiers.orEmpty().map(::convertTier),
additionalCashback = value.additionalCashback.orEmpty().map(::convertAdditional),
)
}
@ -22,4 +24,17 @@ internal object CashbackPromotionsConverter : Converter<CashbackPromotionsRespon
monthlyCapAmount = tier.tierMonthlyCapAmount,
)
}
private fun convertAdditional(
promo: CashbackPromotionsResponse.AdditionalCashback,
): CashbackPromotions.AdditionalCashback {
val endDate = promo.endDate?.let { runCatching { DateTime.parse(it) }.getOrNull() }
return CashbackPromotions.AdditionalCashback(
id = promo.id.orEmpty(),
name = promo.name.orEmpty(),
description = promo.description.orEmpty(),
isPermanent = promo.isPermanent ?: (endDate == null),
endDate = endDate,
)
}
}

View file

@ -99,6 +99,29 @@ internal class MockAwareCashbackRepository @Inject constructor(
monthlyCapAmount = BigDecimal("300"),
),
),
additionalCashback = listOf(
CashbackPromotions.AdditionalCashback(
id = "promo-permanent",
name = "Groceries increase",
description = "+1% cashback for groceries stores",
isPermanent = true,
endDate = null,
),
CashbackPromotions.AdditionalCashback(
id = "promo-groceries-2026",
name = "Groceries increase",
description = "+1% cashback for groceries stores. Max \$10/month",
isPermanent = false,
endDate = DateTime.parse("2026-09-26"),
),
CashbackPromotions.AdditionalCashback(
id = "promo-cashback-2026",
name = "Cashback increase",
description = "+2% cashback for groceries stores. Max \$10/month",
isPermanent = false,
endDate = DateTime.parse("2026-09-26"),
),
),
)
val MOCK_DOCS = listOf(

View file

@ -7,16 +7,25 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/** In MOCK env skips local-storage / signing enrollment; server calls go to WireMock. */
/**
* In MOCK env returns canned onboarding data so the Payment Account shows up as fully loaded on the main
* screen (the entry point to the TangemPay details & cashback screens), and skips local-storage / signing
* enrollment; remaining server calls go to WireMock.
*/
@Singleton
internal class MockAwareOnboardingRepository @Inject constructor(
private val real: DefaultOnboardingRepository,
@ -46,8 +55,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.produceInitialData(userWalletId)
}
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> =
real.getCustomerInfo(userWalletId)
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
if (isMockMode) return MOCK_CUSTOMER_INFO.right()
return real.getCustomerInfo(userWalletId)
}
override suspend fun getBankCredentials(
userWalletId: UserWalletId,
@ -100,8 +111,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.storeVirtualAccountOrderId(userWalletId, vaOrderId)
}
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
if (isMockMode) return true.right()
return real.hasTangemPayInWallet(userWalletId)
}
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> =
real.checkCustomerEligibility()
@ -139,5 +152,52 @@ internal class MockAwareOnboardingRepository @Inject constructor(
private companion object {
const val MOCK_ORDER_ID = "mock-order-id"
const val MOCK_VA_ORDER_ID = "mock-va-order-id"
const val MOCK_CUSTOMER_ID = "mock-customer-id"
const val MOCK_CARD_ID = "mock-card-id"
const val MOCK_PRODUCT_INSTANCE_ID = "mock-product-instance-id"
const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002"
const val MOCK_TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
const val MOCK_POLYGON_CHAIN_ID = 137L
val MOCK_CUSTOMER_INFO = CustomerInfo(
customerId = MOCK_CUSTOMER_ID,
productInstances = listOf(
CustomerInfo.ProductInstance(
id = MOCK_PRODUCT_INSTANCE_ID,
cardId = MOCK_CARD_ID,
frozenState = TangemPayCardFrozenState.Unfrozen,
displayName = null,
actualCardLimit = null,
adminCardLimit = null,
status = CustomerInfo.ProductInstance.Status.ACTIVE,
specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.CARD,
),
),
cards = listOf(
CustomerInfo.CardInfo(
cardId = MOCK_CARD_ID,
cardStatus = TangemPayCard.Status.ACTIVE,
lastFourDigits = "4242",
isPinSet = true,
images = emptyList(),
),
),
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("123.45"),
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "usd-coin",
chainId = MOCK_POLYGON_CHAIN_ID,
depositAddress = MOCK_CUSTOMER_WALLET_ADDRESS,
tokenContractAddress = MOCK_TOKEN_CONTRACT_ADDRESS,
balance = BigDecimal("123.45"),
),
availableForWithdrawal = BigDecimal("123.45"),
tariffPlan = null,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.pay.util
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CashbackPromotionsResponse
import com.tangem.domain.pay.model.CashbackPromotions
import org.joda.time.DateTime
import org.junit.jupiter.api.Test
import java.math.BigDecimal
@ -28,6 +29,87 @@ internal class CashbackPromotionsConverterTest {
monthlyCapAmount = BigDecimal("100"),
),
),
additionalCashback = emptyList(),
),
)
}
@Test
fun `GIVEN additional cashback WHEN convert THEN each promo mapped with its fields`() {
// Arrange
val response = CashbackPromotionsResponse(
cashbackOnCards = null,
additionalCashback = listOf(
additional(id = "p1", name = "Groceries", description = "+1%", isPermanent = true, endDate = null),
additional(
id = "p2",
name = "Cashback",
description = "+2%",
isPermanent = false,
endDate = "2026-09-26",
),
),
)
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result.additionalCashback).containsExactly(
CashbackPromotions.AdditionalCashback(
id = "p1",
name = "Groceries",
description = "+1%",
isPermanent = true,
endDate = null,
),
CashbackPromotions.AdditionalCashback(
id = "p2",
name = "Cashback",
description = "+2%",
isPermanent = false,
endDate = DateTime.parse("2026-09-26"),
),
).inOrder()
}
@Test
fun `GIVEN additional cashback with null isPermanent WHEN convert THEN it is derived from end date`() {
// Arrange
val response = CashbackPromotionsResponse(
cashbackOnCards = null,
additionalCashback = listOf(
additional(isPermanent = null, endDate = null),
additional(isPermanent = null, endDate = "2026-09-26"),
),
)
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result.additionalCashback.map { it.isPermanent }).containsExactly(true, false).inOrder()
}
@Test
fun `GIVEN additional cashback with malformed end date WHEN convert THEN end date null and payload kept`() {
// Arrange
val response = CashbackPromotionsResponse(
cashbackOnCards = null,
additionalCashback = listOf(additional(isPermanent = false, endDate = "not-a-date")),
)
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result.additionalCashback).containsExactly(
CashbackPromotions.AdditionalCashback(
id = "id",
name = "name",
description = "description",
isPermanent = false,
endDate = null,
),
)
}
@ -87,4 +169,18 @@ internal class CashbackPromotionsConverterTest {
tierMonthlyCapAmount = cap,
promotionId = null,
)
private fun additional(
id: String? = "id",
name: String? = "name",
description: String? = "description",
isPermanent: Boolean? = false,
endDate: String? = null,
) = CashbackPromotionsResponse.AdditionalCashback(
id = id,
name = name,
description = description,
isPermanent = isPermanent,
endDate = endDate,
)
}

View file

@ -1,10 +1,12 @@
package com.tangem.domain.pay.model
import org.joda.time.DateTime
import java.math.BigDecimal
/** Cashback program configuration for the customer, from `GET v1/customer/cashback/promotions`. */
data class CashbackPromotions(
val cardTiers: List<CardTier>,
val additionalCashback: List<AdditionalCashback>,
) {
data class CardTier(
@ -14,4 +16,12 @@ data class CashbackPromotions(
val minTransactionAmount: BigDecimal?,
val monthlyCapAmount: BigDecimal?,
)
data class AdditionalCashback(
val id: String,
val name: String,
val description: String,
val isPermanent: Boolean,
val endDate: DateTime?,
)
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.tangempay.cashback.impl.model
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
internal class TangemPayAdditionalCashbackConverter(
private val dateFormatter: TangemPayCashbackDateFormatter = TangemPayCashbackDateFormatter(),
) : Converter<List<CashbackPromotions.AdditionalCashback>, TangemPayAdditionalCashbackUM> {
// TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources
override fun convert(value: List<CashbackPromotions.AdditionalCashback>): TangemPayAdditionalCashbackUM {
return TangemPayAdditionalCashbackUM(
items = value.map { promo ->
TangemPayAdditionalCashbackUM.Item(
id = promo.id,
name = stringReference(promo.name),
description = stringReference(promo.description),
badge = promo.toBadge(),
)
}.toImmutableList(),
)
}
private fun CashbackPromotions.AdditionalCashback.toBadge(): TangemPayAdditionalCashbackUM.Badge {
val expiry = endDate
return if (isPermanent || expiry == null) {
TangemPayAdditionalCashbackUM.Badge.Permanent
} else {
TangemPayAdditionalCashbackUM.Badge.Until(
text = stringReference("Until ${dateFormatter.formatNumericDate(expiry)}"),
)
}
}
}

View file

@ -13,6 +13,8 @@ internal class TangemPayCashbackDateFormatter {
fun formatMonthDay(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateMMMMd)
fun formatNumericDate(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateDDMMYYYY)
fun formatWindow(start: DateTime, end: DateTime): String {
val isSameMonth = start.year == end.year && start.monthOfYear == end.monthOfYear
return if (isSameMonth) {

View file

@ -21,6 +21,7 @@ import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackDet
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackScreenUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -45,9 +46,10 @@ internal class TangemPayCashbackModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<TangemPayCashbackNavigation> = SlotNavigation()
private val cashbackConverter = TangemPayCashbackUmConverter(onCloseClick = router::pop)
private val cashbackConverter = TangemPayCashbackUmConverter()
private val histogramConverter = TangemPayCashbackHistogramConverter()
private val tiersConverter = TangemPayCashbackTiersConverter()
private val additionalCashbackConverter = TangemPayAdditionalCashbackConverter()
private val infoTilesConverter = TangemPayCashbackInfoTilesConverter(
onRateClick = { bottomSheetNavigation.activate(TangemPayCashbackNavigation.Details) },
onAccrualsClick = { bottomSheetNavigation.activate(TangemPayCashbackNavigation.Accruals) },
@ -62,40 +64,50 @@ internal class TangemPayCashbackModel @Inject constructor(
field = MutableStateFlow(accrualsConverter.convert(emptyList()))
val uiState: StateFlow<TangemPayCashbackScreenUM>
field = MutableStateFlow(
TangemPayCashbackScreenUM(
cashback = cashbackConverter.convert(value = null),
infoTiles = null,
histogram = null,
),
field = MutableStateFlow<TangemPayCashbackScreenUM>(
TangemPayCashbackScreenUM.Loading(onCloseClick = router::pop),
)
private var loadJob: Job? = null
init {
loadCashback()
}
private fun loadCashback() {
modelScope.launch {
loadJob?.cancel()
uiState.value = TangemPayCashbackScreenUM.Loading(onCloseClick = router::pop)
loadJob = modelScope.launch {
val summaryDeferred = async { loadSummary() }
val promotionsDeferred = async { loadPromotions() }
val docsDeferred = async { loadDocs() }
val planDeferred = async { loadPlan() }
val summary = summaryDeferred.await()
val history = if (summary is CashbackSummary.Enabled) loadHistory() else null
val promotions = promotionsDeferred.await()
if (summary == null && promotions == null) {
uiState.value = TangemPayCashbackScreenUM.Error(
onCloseClick = router::pop,
onReloadClick = ::loadCashback,
)
return@launch
}
val history = if (summary is CashbackSummary.Enabled) loadHistory() else null
val plan = planDeferred.await()
val tiers = promotions?.let(tiersConverter::convert).orEmpty()
uiState.value = TangemPayCashbackScreenUM(
uiState.value = TangemPayCashbackScreenUM.Content(
onCloseClick = router::pop,
cashback = cashbackConverter.convert((summary as? CashbackSummary.Enabled)?.cashback),
infoTiles = promotions?.let {
infoTilesConverter.convert(
tiers = tiers,
currentPlan = plan,
)
infoTilesConverter.convert(tiers = tiers, currentPlan = plan)
},
histogram = history?.takeIf { it.months.isNotEmpty() }?.let(histogramConverter::convert),
additionalCashback = promotions
?.let { additionalCashbackConverter.convert(it.additionalCashback) }
?.takeIf { it.items.isNotEmpty() },
)
detailsSheet.value = detailsConverter.convert(tiers)
accrualsSheet.value = accrualsConverter.convert(docsDeferred.await())

View file

@ -10,7 +10,6 @@ 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> {
@ -22,7 +21,6 @@ internal class TangemPayCashbackUmConverter(
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = onCloseClick,
)
}
val currency = getJavaCurrencyByCode(value.currency)
@ -48,7 +46,6 @@ internal class TangemPayCashbackUmConverter(
subtitle = stringReference("Will be deposited on $payoutWindow"),
isEmpty = false,
banner = banner,
onCloseClick = onCloseClick,
)
}
}

View file

@ -0,0 +1,145 @@
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.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun TangemPayAdditionalCashback(state: TangemPayAdditionalCashbackUM, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(16.dp),
) {
Text(
// TODO([REDACTED_TASK_KEY]): move to string resources
text = stringReference("Additional cashback").resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
state.items.forEach { item ->
AdditionalCashbackCard(item = item)
}
}
}
}
@Composable
private fun AdditionalCashbackCard(item: TangemPayAdditionalCashbackUM.Item, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.secondary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CashbackBadge(badge = item.badge)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = item.name.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
Text(
text = item.description.resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
}
@Composable
private fun CashbackBadge(badge: TangemPayAdditionalCashbackUM.Badge, modifier: Modifier = Modifier) {
when (badge) {
TangemPayAdditionalCashbackUM.Badge.Permanent -> TangemBadge(
// TODO([REDACTED_TASK_KEY]): move to string resources
text = stringReference("Permanent"),
modifier = modifier,
status = TangemBadge.Status.Neutral,
size = TangemBadge.Size.X6,
)
is TangemPayAdditionalCashbackUM.Badge.Until -> TangemBadge(
text = badge.text,
modifier = modifier,
status = TangemBadge.Status.Info,
size = TangemBadge.Size.X6,
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_clock_24),
)
}
}
@Preview(showBackground = true, widthDp = 402)
@Preview(showBackground = true, widthDp = 402, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayAdditionalCashbackPreview(
@PreviewParameter(TangemPayAdditionalCashbackPreviewProvider::class) state: TangemPayAdditionalCashbackUM,
) {
TangemThemePreviewRedesign {
TangemPayAdditionalCashback(
state = state,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}
private class TangemPayAdditionalCashbackPreviewProvider :
CollectionPreviewParameterProvider<TangemPayAdditionalCashbackUM>(
listOf(
TangemPayAdditionalCashbackUM(
items = persistentListOf(
TangemPayAdditionalCashbackUM.Item(
id = "1",
name = stringReference("Groceries increase"),
description = stringReference("+1% cashback for groceries stores"),
badge = TangemPayAdditionalCashbackUM.Badge.Permanent,
),
TangemPayAdditionalCashbackUM.Item(
id = "2",
name = stringReference("Groceries increase"),
description = stringReference("+1% cashback for groceries stores. Max \$10/month"),
badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")),
),
TangemPayAdditionalCashbackUM.Item(
id = "3",
name = stringReference("Cashback increase"),
description = stringReference("+2% cashback for groceries stores. Max \$10/month"),
badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")),
),
),
),
),
)

View file

@ -2,15 +2,23 @@ package com.tangem.features.tangempay.cashback.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
@ -24,19 +32,23 @@ 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.semantics.Role
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 androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
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.TangemPayAdditionalCashbackUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackInfoTilesUM
@ -50,52 +62,114 @@ private const val GLOW_RADIUS_FACTOR = 0.585f
private const val GLOW_BLUE_ALPHA = 0.20f
private const val GLOW_WARM_ALPHA = 0.15f
private val TopBarHeight: Dp = 68.dp
@Composable
internal fun TangemPayCashbackScreen(state: TangemPayCashbackScreenUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary),
) {
if (state.cashback.isEmpty) {
EmptyStateGlow(modifier = Modifier.fillMaxSize())
}
Column(modifier = Modifier.fillMaxSize()) {
TangemTopNavigation(
// TODO([REDACTED_TASK_KEY]): move to string resources
title = stringReference("Cashback"),
contentAlign = TangemTopNavigation.ContentAlign.Center,
onClose = state.cashback.onCloseClick,
)
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
HeroBlock(state = state.cashback)
state.cashback.banner?.let { banner ->
CashbackBanner(
banner = banner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
state.infoTiles?.let { infoTiles ->
TangemPayCashbackInfoTiles(
state = infoTiles,
modifier = Modifier.padding(top = 24.dp),
)
}
state.histogram?.let { histogram ->
TangemPayCashbackHistogram(
state = histogram,
modifier = Modifier.padding(top = 24.dp),
)
}
val topPadding = TopBarHeight + WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem()
.background(TangemTheme.colors3.bg.primary),
) {
if (state is TangemPayCashbackScreenUM.Content && state.cashback.isEmpty) {
EmptyStateGlow(modifier = Modifier.fillMaxSize())
}
when (state) {
is TangemPayCashbackScreenUM.Loading -> TangemPayCashbackShimmer(
modifier = Modifier
.fillMaxSize()
.padding(top = topPadding),
)
is TangemPayCashbackScreenUM.Error -> CashbackError(
onReloadClick = state.onReloadClick,
modifier = Modifier
.fillMaxSize()
.padding(top = topPadding)
.navigationBarsPadding(),
)
is TangemPayCashbackScreenUM.Content -> CashbackContent(
state = state,
topPadding = topPadding,
modifier = Modifier.fillMaxSize(),
)
}
}
TangemTopNavigation(
// TODO([REDACTED_TASK_KEY]): move to string resources
title = stringReference("Cashback"),
contentAlign = TangemTopNavigation.ContentAlign.Center,
onClose = state.onCloseClick,
)
}
}
@Composable
private fun CashbackContent(state: TangemPayCashbackScreenUM.Content, topPadding: Dp, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(top = topPadding, bottom = 16.dp)
.navigationBarsPadding(),
) {
HeroBlock(state = state.cashback)
state.cashback.banner?.let { banner ->
CashbackBanner(
banner = banner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
state.infoTiles?.let { infoTiles ->
TangemPayCashbackInfoTiles(state = infoTiles, modifier = Modifier.padding(top = 24.dp))
}
state.histogram?.let { histogram ->
TangemPayCashbackHistogram(state = histogram, modifier = Modifier.padding(top = 24.dp))
}
state.additionalCashback?.let { additionalCashback ->
TangemPayAdditionalCashback(state = additionalCashback, modifier = Modifier.padding(top = 24.dp))
}
}
}
@Composable
private fun CashbackError(onReloadClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(horizontal = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.inverse)
.clickable(role = Role.Button, onClick = onReloadClick)
.padding(10.dp),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(CoreUiR.drawable.ic_refresh_24),
// TODO([REDACTED_TASK_KEY]): move to string resources
contentDescription = stringReference("Reload").resolveReference(),
tint = TangemTheme.colors3.icon.inverse,
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
// TODO([REDACTED_TASK_KEY]): move to string resources
text = stringReference("Failed to load page.\nTap to reload").resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
)
}
}
@ -199,7 +273,8 @@ private fun TangemPayCashbackScreenPreview(
private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProvider<TangemPayCashbackScreenUM>(
collection = listOf(
TangemPayCashbackScreenUM(
TangemPayCashbackScreenUM.Content(
onCloseClick = {},
cashback = TangemPayCashbackUM(
title = stringReference("$22.54 earned in June"),
subtitle = stringReference("Will be deposited on July 15"),
@ -208,38 +283,25 @@ private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProv
text = stringReference("Cashback $22.54 for June will be deposited till July 5"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = {},
),
infoTiles = previewInfoTiles(),
histogram = previewHistogram(),
additionalCashback = previewAdditionalCashback(),
),
TangemPayCashbackScreenUM(
cashback = 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 = {},
),
infoTiles = null,
histogram = null,
),
TangemPayCashbackScreenUM(
TangemPayCashbackScreenUM.Content(
onCloseClick = {},
cashback = TangemPayCashbackUM(
title = stringReference("Start spending and earn cashback"),
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = {},
),
infoTiles = null,
histogram = null,
additionalCashback = null,
),
TangemPayCashbackScreenUM.Loading(onCloseClick = {}),
TangemPayCashbackScreenUM.Error(onCloseClick = {}, onReloadClick = {}),
),
)
@ -276,4 +338,21 @@ private fun previewHistogram(): TangemPayCashbackHistogramUM {
bar(month = "Jun", amount = "$32.15", value = 32.15f, style = Style.Highlighted),
),
)
}
}
private fun previewAdditionalCashback() = TangemPayAdditionalCashbackUM(
items = persistentListOf(
TangemPayAdditionalCashbackUM.Item(
id = "1",
name = stringReference("Groceries increase"),
description = stringReference("+1% cashback for groceries stores"),
badge = TangemPayAdditionalCashbackUM.Badge.Permanent,
),
TangemPayAdditionalCashbackUM.Item(
id = "2",
name = stringReference("Cashback increase"),
description = stringReference("+2% cashback for groceries stores. Max \$10/month"),
badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")),
),
),
)

View file

@ -0,0 +1,143 @@
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun TangemPayCashbackShimmer(modifier: Modifier = Modifier) {
ProvideTangemShimmer {
Column(modifier = modifier.fillMaxWidth()) {
HeroShimmer()
InfoTilesShimmer(modifier = Modifier.padding(top = 24.dp))
HistogramShimmer(modifier = Modifier.padding(top = 24.dp))
AdditionalCashbackShimmer(modifier = Modifier.padding(top = 24.dp))
}
}
}
@Composable
private fun HeroShimmer(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemShimmer(style = TangemTheme.typography3.heading.medium, textAlign = TextAlign.Center)
TangemShimmer(style = TangemTheme.typography3.subheading.medium, textAlign = TextAlign.Center)
}
}
@Composable
private fun InfoTilesShimmer(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TangemShimmer(modifier = Modifier.weight(1f).height(108.dp), radius = 16.dp)
TangemShimmer(modifier = Modifier.weight(1f).height(108.dp), radius = 16.dp)
}
}
@Composable
private fun HistogramShimmer(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
SectionHeaderShimmer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Bottom,
) {
BAR_HEIGHTS.forEach { barHeight ->
TangemShimmer(modifier = Modifier.weight(1f).height(barHeight), radius = 8.dp)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(TangemTheme.colors3.border.primary),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
repeat(BAR_HEIGHTS.size) {
TangemShimmer(
style = TangemTheme.typography3.caption.medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
private fun AdditionalCashbackShimmer(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
SectionHeaderShimmer()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(times = 3) {
TangemShimmer(modifier = Modifier.fillMaxWidth().height(108.dp), radius = 24.dp)
}
}
}
}
@Composable
private fun SectionHeaderShimmer(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(16.dp),
) {
TangemShimmer(style = TangemTheme.typography3.heading.small)
}
}
private val BAR_HEIGHTS: List<Dp> = listOf(38.dp, 115.dp, 102.dp, 59.dp, 77.dp)
@Preview(showBackground = true, widthDp = 402)
@Preview(showBackground = true, widthDp = 402, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayCashbackShimmerPreview() {
TangemThemePreviewRedesign {
TangemPayCashbackShimmer(modifier = Modifier.background(TangemTheme.colors3.bg.primary))
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.tangempay.cashback.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
/**
* State for the "Additional cashback" section on the Cashback screen.
*
* @property items one card per active additional/bonus promotion, ordered as returned by the BFF
*/
@Immutable
internal data class TangemPayAdditionalCashbackUM(
val items: ImmutableList<Item>,
) {
/**
* @property name short promotion name shown above the description
* @property description one-line promotion description
* @property badge validity badge permanent or time-limited
*/
@Immutable
data class Item(
val id: String,
val name: TextReference,
val description: TextReference,
val badge: Badge,
)
/** Validity badge shown at the top of an additional-cashback card. */
@Immutable
sealed interface Badge {
/** No expiry — rendered as a neutral "Permanent" pill. */
data object Permanent : Badge
/** Time-limited — rendered as an info "Until <date>" pill with a clock icon. */
data class Until(val text: TextReference) : Badge
}
}

View file

@ -2,9 +2,29 @@ package com.tangem.features.tangempay.cashback.impl.ui.state
import androidx.compose.runtime.Immutable
/** Top-level state of the Cashback screen. The top navigation (title + close) is shown in every state. */
@Immutable
internal data class TangemPayCashbackScreenUM(
val cashback: TangemPayCashbackUM,
val infoTiles: TangemPayCashbackInfoTilesUM?,
val histogram: TangemPayCashbackHistogramUM?,
)
internal sealed interface TangemPayCashbackScreenUM {
val onCloseClick: () -> Unit
/** Skeleton placeholder shown while the screen data is loading. */
data class Loading(
override val onCloseClick: () -> Unit,
) : TangemPayCashbackScreenUM
/** The page failed to load; the user can tap to retry. */
data class Error(
override val onCloseClick: () -> Unit,
val onReloadClick: () -> Unit,
) : TangemPayCashbackScreenUM
/** Loaded content. Each section is nullable and hidden when its data is unavailable. */
data class Content(
override val onCloseClick: () -> Unit,
val cashback: TangemPayCashbackUM,
val infoTiles: TangemPayCashbackInfoTilesUM?,
val histogram: TangemPayCashbackHistogramUM?,
val additionalCashback: TangemPayAdditionalCashbackUM?,
) : TangemPayCashbackScreenUM
}

View file

@ -9,7 +9,6 @@ internal data class TangemPayCashbackUM(
val subtitle: TextReference,
val isEmpty: Boolean,
val banner: Banner?,
val onCloseClick: () -> Unit,
) {
@Immutable

View file

@ -0,0 +1,103 @@
package com.tangem.features.tangempay.cashback.impl.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.joda.time.DateTime
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
private const val FORMATTED_DATE = "26.09.2026"
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayAdditionalCashbackConverterTest {
private val dateFormatter: TangemPayCashbackDateFormatter = mockk()
private val converter = TangemPayAdditionalCashbackConverter(dateFormatter)
@BeforeEach
fun setup() {
clearMocks(dateFormatter)
every { dateFormatter.formatNumericDate(any()) } returns FORMATTED_DATE
}
@Test
fun `GIVEN empty list WHEN convert THEN no items`() {
// Act
val result = converter.convert(emptyList())
// Assert
assertThat(result.items).isEmpty()
}
@Test
fun `GIVEN promo WHEN convert THEN item fields mapped`() {
// Arrange
val promo = additional(id = "1", name = "Groceries increase", description = "+1%", isPermanent = true)
// Act
val result = converter.convert(listOf(promo))
// Assert
assertThat(result.items).containsExactly(
TangemPayAdditionalCashbackUM.Item(
id = "1",
name = stringReference("Groceries increase"),
description = stringReference("+1%"),
badge = TangemPayAdditionalCashbackUM.Badge.Permanent,
),
)
}
@ParameterizedTest
@MethodSource("badgeCases")
fun `GIVEN permanence and end date WHEN convert THEN badge reflects them`(model: BadgeCase) {
// Act
val result = converter.convert(listOf(additional(isPermanent = model.isPermanent, endDate = model.endDate)))
// Assert
assertThat(result.items.single().badge).isEqualTo(model.expected)
}
private fun badgeCases() = listOf(
BadgeCase(isPermanent = true, endDate = null, expected = TangemPayAdditionalCashbackUM.Badge.Permanent),
BadgeCase(isPermanent = true, endDate = DATE, expected = TangemPayAdditionalCashbackUM.Badge.Permanent),
BadgeCase(isPermanent = false, endDate = null, expected = TangemPayAdditionalCashbackUM.Badge.Permanent),
BadgeCase(
isPermanent = false,
endDate = DATE,
expected = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until $FORMATTED_DATE")),
),
)
internal data class BadgeCase(
val isPermanent: Boolean,
val endDate: DateTime?,
val expected: TangemPayAdditionalCashbackUM.Badge,
)
private fun additional(
id: String = "id",
name: String = "name",
description: String = "description",
isPermanent: Boolean = false,
endDate: DateTime? = null,
) = CashbackPromotions.AdditionalCashback(
id = id,
name = name,
description = description,
isPermanent = isPermanent,
endDate = endDate,
)
private companion object {
val DATE: DateTime = DateTime.parse("2026-09-26")
}
}

View file

@ -67,4 +67,13 @@ internal class TangemPayCashbackDateFormatterTest {
// Assert
assertThat(actual).isEqualTo("July 30 August 2")
}
@Test
fun `GIVEN date WHEN formatNumericDate THEN numeric day month year`() {
// Act
val actual = formatter.formatNumericDate(DateTime.parse("2026-09-26"))
// Assert
assertThat(actual).isEqualTo("26.09.2026")
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.cashback.impl.model
import android.text.format.DateFormat
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
@ -19,7 +20,9 @@ import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackScreenUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
@ -70,8 +73,8 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.infoTiles).isNotNull()
assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%"))
assertThat(model.content().infoTiles).isNotNull()
assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%"))
assertThat(model.detailsSheet.value.rows).hasSize(2)
assertThat(model.accrualsSheet.value.docRows).hasSize(2)
}
@ -86,7 +89,7 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 2%"))
assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 2%"))
}
@Test
@ -98,7 +101,7 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.infoTiles).isNull()
assertThat(model.content().infoTiles).isNull()
assertThat(model.detailsSheet.value.rows).isEmpty()
}
@ -111,10 +114,53 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.infoTiles).isNotNull()
assertThat(model.content().infoTiles).isNotNull()
assertThat(model.detailsSheet.value.rows).hasSize(2)
}
@Test
fun `GIVEN summary and promotions both fail WHEN model created THEN error state`() {
// Arrange
coEvery { cashbackRepository.getCashbackSummary(any()) } throws RuntimeException("boom")
coEvery { cashbackRepository.getCashbackPromotions(any()) } throws RuntimeException("boom")
// Act
val model = createModel()
// Assert
assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Error::class.java)
}
@Test
fun `GIVEN summary and promotions both return error WHEN model created THEN error state`() {
// Arrange
coEvery { cashbackRepository.getCashbackSummary(any()) } returns VisaApiError.Unspecified.left()
coEvery { cashbackRepository.getCashbackPromotions(any()) } returns VisaApiError.Unspecified.left()
// Act
val model = createModel()
// Assert
assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Error::class.java)
}
@Test
fun `GIVEN error state WHEN reload succeeds THEN content shown`() {
// Arrange
coEvery { cashbackRepository.getCashbackSummary(any()) } throws RuntimeException("boom")
coEvery { cashbackRepository.getCashbackPromotions(any()) } throws RuntimeException("boom")
val model = createModel()
val error = model.uiState.value as TangemPayCashbackScreenUM.Error
coEvery { cashbackRepository.getCashbackSummary(any()) } returns CashbackSummary.Disabled.right()
coEvery { cashbackRepository.getCashbackPromotions(any()) } returns promotions().right()
// Act
error.onReloadClick()
// Assert
assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Content::class.java)
}
@Test
fun `GIVEN customer info fails WHEN model created THEN rate tile falls back to the first tier`() {
// Arrange
@ -124,7 +170,7 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%"))
assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%"))
}
@Test
@ -140,6 +186,28 @@ internal class TangemPayCashbackModelTest {
assertThat(model.accrualsSheet.value.infoRows).isNotEmpty()
}
@Test
fun `GIVEN additional cashback WHEN model created THEN additional cashback section populated`() {
// Arrange
coEvery { cashbackRepository.getCashbackPromotions(any()) } returns
promotions(additional = listOf(additionalPromo())).right()
// Act
val model = createModel()
// Assert
assertThat(model.content().additionalCashback?.items).hasSize(1)
}
@Test
fun `GIVEN no additional cashback WHEN model created THEN additional cashback section hidden`() {
// Act
val model = createModel()
// Assert
assertThat(model.content().additionalCashback).isNull()
}
@Test
fun `GIVEN enabled summary and history WHEN model created THEN histogram populated`() {
// Arrange
@ -150,8 +218,8 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.histogram).isNotNull()
assertThat(model.uiState.value.histogram?.bars).hasSize(2)
assertThat(model.content().histogram).isNotNull()
assertThat(model.content().histogram?.bars).hasSize(2)
}
@Test
@ -160,7 +228,7 @@ internal class TangemPayCashbackModelTest {
val model = createModel()
// Assert
assertThat(model.uiState.value.histogram).isNull()
assertThat(model.content().histogram).isNull()
coVerify(exactly = 0) { cashbackRepository.getCashbackHistory(any(), any()) }
}
@ -173,7 +241,12 @@ internal class TangemPayCashbackModelTest {
onboardingRepository = onboardingRepository,
)
private fun promotions() = CashbackPromotions(
private fun TangemPayCashbackModel.content(): TangemPayCashbackScreenUM.Content =
uiState.value as TangemPayCashbackScreenUM.Content
private fun promotions(
additional: List<CashbackPromotions.AdditionalCashback> = emptyList(),
) = CashbackPromotions(
cardTiers = listOf(
CashbackPromotions.CardTier(
tier = "basic",
@ -190,6 +263,15 @@ internal class TangemPayCashbackModelTest {
monthlyCapAmount = BigDecimal("300"),
),
),
additionalCashback = additional,
)
private fun additionalPromo() = CashbackPromotions.AdditionalCashback(
id = "promo-1",
name = "Groceries increase",
description = "+1% cashback for groceries stores",
isPermanent = true,
endDate = null,
)
private fun docs() = listOf(

View file

@ -79,13 +79,14 @@ internal class TangemPayCashbackTiersConverterTest {
@Test
fun `GIVEN no tiers WHEN convert THEN empty list`() {
// Act
val result = converter.convert(CashbackPromotions(cardTiers = emptyList()))
val result = converter.convert(CashbackPromotions(cardTiers = emptyList(), additionalCashback = emptyList()))
// Assert
assertThat(result).isEmpty()
}
private fun promotions(vararg tiers: CashbackPromotions.CardTier) = CashbackPromotions(cardTiers = tiers.toList())
private fun promotions(vararg tiers: CashbackPromotions.CardTier) =
CashbackPromotions(cardTiers = tiers.toList(), additionalCashback = emptyList())
private fun tier(
id: String = "basic",

View file

@ -23,8 +23,7 @@ internal class TangemPayCashbackUmConverterTest {
private val defaultLocale = Locale.getDefault()
private val onCloseClick: () -> Unit = {}
private val converter = TangemPayCashbackUmConverter(onCloseClick = onCloseClick)
private val converter = TangemPayCashbackUmConverter()
@BeforeEach
fun setup() {
@ -51,7 +50,6 @@ internal class TangemPayCashbackUmConverterTest {
subtitle = stringReference("Collected amount will be shown here"),
isEmpty = true,
banner = null,
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@ -73,7 +71,6 @@ internal class TangemPayCashbackUmConverterTest {
text = stringReference("Cashback $22.54 for June will be deposited till July 5"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@ -97,7 +94,6 @@ internal class TangemPayCashbackUmConverterTest {
),
type = TangemPayCashbackUM.Banner.Type.Error,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}
@ -122,7 +118,6 @@ internal class TangemPayCashbackUmConverterTest {
text = stringReference("Cashback $22.54 for June will be deposited till August 2"),
type = TangemPayCashbackUM.Banner.Type.Info,
),
onCloseClick = onCloseClick,
)
assertThat(actual).isEqualTo(expected)
}