Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 17:43:51 +03:00
parent 15a518c576
commit 78cb0fc750
18 changed files with 747 additions and 15 deletions

View file

@ -202,4 +202,10 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Header("Accept-Language") language: String,
): ApiResponse<CashbackAccrualDocsResponse>
@GET("v1/customer/cashback/history")
suspend fun getCashbackHistory(
@Header("Authorization") authHeader: String,
@Query("months") months: Int,
): ApiResponse<CashbackHistoryResponse>
}

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response from `GET /v1/customer/cashback/history`.
*
* Confirmed cashback grouped by calendar month, ordered oldest to newest. Drives the monthly
* earnings histogram on the Cashback screen.
*/
@JsonClass(generateAdapter = true)
data class CashbackHistoryResponse(
@Json(name = "currency") val currency: String?,
@Json(name = "items") val items: List<Item>?,
) {
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "year") val year: Int,
@Json(name = "month") val month: Int,
@Json(name = "confirmed_amount") val confirmedAmount: BigDecimal?,
)
}

View file

@ -115,6 +115,13 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("MMMM")
}
/**
* Example: "Jun"
*/
val dateMMM: DateTimeFormatter by lazy {
getBestFormatterBySkeleton("MMM")
}
/**
* Example: "June 1"
*/

View file

@ -3,11 +3,13 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.data.pay.util.CashbackAccrualDocsConverter
import com.tangem.data.pay.util.CashbackHistoryConverter
import com.tangem.data.pay.util.CashbackPromotionsConverter
import com.tangem.data.pay.util.CashbackSummaryConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.repository.CashbackRepository
@ -47,6 +49,15 @@ internal class DefaultCashbackRepository @Inject constructor(
}.map(CashbackAccrualDocsConverter::convert)
}
override suspend fun getCashbackHistory(
userWalletId: UserWalletId,
months: Int,
): Either<VisaApiError, CashbackHistory> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCashbackHistory(authHeader = authHeader, months = months)
}.map(CashbackHistoryConverter::convert)
}
override suspend fun isDeactivationBannerDismissed(userWalletId: UserWalletId): Boolean {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
return storage.getCashbackDeactivationDismissed(customerWalletAddress)

View file

@ -0,0 +1,23 @@
package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.CashbackHistoryResponse
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/** Maps [CashbackHistoryResponse] (BFF) to the domain [CashbackHistory]. */
internal object CashbackHistoryConverter : Converter<CashbackHistoryResponse, CashbackHistory> {
override fun convert(value: CashbackHistoryResponse): CashbackHistory {
return CashbackHistory(
currency = value.currency.orEmpty(),
months = value.items.orEmpty().map { item ->
CashbackHistory.MonthlyCashback(
year = item.year,
month = item.month,
confirmedAmount = item.confirmedAmount ?: BigDecimal.ZERO,
)
},
)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.model.TangemPayCashback
@ -49,6 +50,14 @@ internal class MockAwareCashbackRepository @Inject constructor(
return real.getCashbackAccrualDocs(userWalletId)
}
override suspend fun getCashbackHistory(
userWalletId: UserWalletId,
months: Int,
): Either<VisaApiError, CashbackHistory> {
if (isMockMode) return MOCK_HISTORY.copy(months = MOCK_HISTORY.months.takeLast(months)).right()
return real.getCashbackHistory(userWalletId, months)
}
override suspend fun isDeactivationBannerDismissed(userWalletId: UserWalletId): Boolean =
real.isDeactivationBannerDismissed(userWalletId)
@ -104,5 +113,16 @@ internal class MockAwareCashbackRepository @Inject constructor(
url = "https://tangem.com/docs/en/tangem-pay-cashback-terms.pdf",
),
)
val MOCK_HISTORY = CashbackHistory(
currency = "USD",
months = listOf(
CashbackHistory.MonthlyCashback(year = 2026, month = 2, confirmedAmount = BigDecimal("12.02")),
CashbackHistory.MonthlyCashback(year = 2026, month = 3, confirmedAmount = BigDecimal("44.22")),
CashbackHistory.MonthlyCashback(year = 2026, month = 4, confirmedAmount = BigDecimal("38.52")),
CashbackHistory.MonthlyCashback(year = 2026, month = 5, confirmedAmount = BigDecimal("26.10")),
CashbackHistory.MonthlyCashback(year = 2026, month = 6, confirmedAmount = BigDecimal("22.54")),
),
)
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.data.pay.util
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CashbackHistoryResponse
import com.tangem.domain.pay.model.CashbackHistory
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CashbackHistoryConverterTest {
@ParameterizedTest
@MethodSource("provideTestModels")
fun convert(model: ConvertModel) {
// Act
val actual = CashbackHistoryConverter.convert(model.response)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
ConvertModel(
name = "full response -> mapped history ordered oldest to newest",
response = createResponse(
currency = "USD",
items = listOf(
createItem(year = 2026, month = 2, confirmedAmount = BigDecimal("5.00")),
createItem(year = 2026, month = 6, confirmedAmount = BigDecimal("22.54")),
),
),
expected = CashbackHistory(
currency = "USD",
months = listOf(
CashbackHistory.MonthlyCashback(year = 2026, month = 2, confirmedAmount = BigDecimal("5.00")),
CashbackHistory.MonthlyCashback(year = 2026, month = 6, confirmedAmount = BigDecimal("22.54")),
),
),
),
ConvertModel(
name = "negative amount preserved for current-month refund",
response = createResponse(
items = listOf(createItem(year = 2026, month = 6, confirmedAmount = BigDecimal("-2.15"))),
),
expected = CashbackHistory(
currency = "USD",
months = listOf(
CashbackHistory.MonthlyCashback(year = 2026, month = 6, confirmedAmount = BigDecimal("-2.15")),
),
),
),
ConvertModel(
name = "null confirmed_amount -> ZERO",
response = createResponse(
items = listOf(createItem(year = 2026, month = 6, confirmedAmount = null)),
),
expected = CashbackHistory(
currency = "USD",
months = listOf(
CashbackHistory.MonthlyCashback(year = 2026, month = 6, confirmedAmount = BigDecimal.ZERO),
),
),
),
ConvertModel(
name = "null currency -> empty string",
response = createResponse(currency = null, items = emptyList()),
expected = CashbackHistory(currency = "", months = emptyList()),
),
ConvertModel(
name = "null items -> empty months",
response = createResponse(items = null),
expected = CashbackHistory(currency = "USD", months = emptyList()),
),
)
internal data class ConvertModel(
val name: String,
val response: CashbackHistoryResponse,
val expected: CashbackHistory,
) {
override fun toString(): String = name
}
private companion object {
fun createResponse(
currency: String? = "USD",
items: List<CashbackHistoryResponse.Item>? = listOf(
createItem(year = 2026, month = 6, confirmedAmount = BigDecimal("22.54")),
),
) = CashbackHistoryResponse(
currency = currency,
items = items,
)
fun createItem(year: Int, month: Int, confirmedAmount: BigDecimal?) = CashbackHistoryResponse.Item(
year = year,
month = month,
confirmedAmount = confirmedAmount,
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.pay.model
import java.math.BigDecimal
/**
* Customer cashback history from `GET /v1/customer/cashback/history`.
*
* Confirmed cashback grouped by calendar month. Drives the monthly earnings histogram on the
* Cashback screen.
*/
data class CashbackHistory(
val currency: String,
/** Confirmed cashback per calendar month, ordered oldest to newest. */
val months: List<MonthlyCashback>,
) {
data class MonthlyCashback(
val year: Int,
/** 1-based calendar month (6 = June). */
val month: Int,
/** Total confirmed cashback for this month; negative for refunds. */
val confirmedAmount: BigDecimal,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.visa.error.VisaApiError
@ -19,6 +20,13 @@ interface CashbackRepository {
suspend fun getCashbackAccrualDocs(userWalletId: UserWalletId): Either<VisaApiError, List<CashbackDocument>>
/**
* Loads the confirmed cashback history for the customer of [userWalletId], grouped by month.
*
* @param months number of calendar months to return, counting back from and including the current month.
*/
suspend fun getCashbackHistory(userWalletId: UserWalletId, months: Int): Either<VisaApiError, CashbackHistory>
/** Whether the "Cashback deactivated" banner was permanently dismissed for [userWalletId]. */
suspend fun isDeactivationBannerDismissed(userWalletId: UserWalletId): Boolean

View file

@ -8,6 +8,9 @@ internal class TangemPayCashbackDateFormatter {
fun formatMonth(year: Int, month: Int): String =
DateTimeFormatters.formatDate(DateTime(year, month, 1, 0, 0), DateTimeFormatters.dateMMMM)
fun formatShortMonth(year: Int, month: Int): String =
DateTimeFormatters.formatDate(DateTime(year, month, 1, 0, 0), DateTimeFormatters.dateMMM)
fun formatMonthDay(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateMMMMd)
fun formatWindow(start: DateTime, end: DateTime): String {

View file

@ -0,0 +1,44 @@
package com.tangem.features.tangempay.cashback.impl.model
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.defaultAmount
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.CashbackHistory
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal class TangemPayCashbackHistogramConverter(
private val dateFormatter: TangemPayCashbackDateFormatter = TangemPayCashbackDateFormatter(),
) : Converter<CashbackHistory, TangemPayCashbackHistogramUM> {
// TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources
override fun convert(value: CashbackHistory): TangemPayCashbackHistogramUM {
val currency = getJavaCurrencyByCode(value.currency)
val total = value.months.fold(BigDecimal.ZERO) { acc, month -> acc + month.confirmedAmount }
val formattedTotal = total.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }
val lastIndex = value.months.lastIndex
return TangemPayCashbackHistogramUM(
title = stringReference("$formattedTotal earned in total"),
bars = value.months.mapIndexed { index, month ->
TangemPayCashbackHistogramUM.Bar(
month = stringReference(dateFormatter.formatShortMonth(month.year, month.month)),
amount = stringReference(
month.confirmedAmount.format { fiat(currency.currencyCode, currency.symbol).defaultAmount() },
),
amountValue = month.confirmedAmount.toFloat(),
style = when {
index != lastIndex -> Style.Regular
month.confirmedAmount.signum() < 0 -> Style.HighlightedNegative
else -> Style.Highlighted
},
)
}.toImmutableList(),
)
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.repository.CashbackRepository
@ -26,6 +27,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val CASHBACK_HISTORY_MONTHS = 5
@Stable
@ModelScoped
internal class TangemPayCashbackModel @Inject constructor(
@ -43,6 +46,7 @@ internal class TangemPayCashbackModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<TangemPayCashbackNavigation> = SlotNavigation()
private val cashbackConverter = TangemPayCashbackUmConverter(onCloseClick = router::pop)
private val histogramConverter = TangemPayCashbackHistogramConverter()
private val tiersConverter = TangemPayCashbackTiersConverter()
private val infoTilesConverter = TangemPayCashbackInfoTilesConverter(
onRateClick = { bottomSheetNavigation.activate(TangemPayCashbackNavigation.Details) },
@ -62,6 +66,7 @@ internal class TangemPayCashbackModel @Inject constructor(
TangemPayCashbackScreenUM(
cashback = cashbackConverter.convert(value = null),
infoTiles = null,
histogram = null,
),
)
@ -77,6 +82,7 @@ internal class TangemPayCashbackModel @Inject constructor(
val planDeferred = async { loadPlan() }
val summary = summaryDeferred.await()
val history = if (summary is CashbackSummary.Enabled) loadHistory() else null
val promotions = promotionsDeferred.await()
val plan = planDeferred.await()
val tiers = promotions?.let(tiersConverter::convert).orEmpty()
@ -89,6 +95,7 @@ internal class TangemPayCashbackModel @Inject constructor(
currentPlan = plan,
)
},
histogram = history?.takeIf { it.months.isNotEmpty() }?.let(histogramConverter::convert),
)
detailsSheet.value = detailsConverter.convert(tiers)
accrualsSheet.value = accrualsConverter.convert(docsDeferred.await())
@ -98,6 +105,10 @@ internal class TangemPayCashbackModel @Inject constructor(
private suspend fun loadSummary(): CashbackSummary? =
runSuspendCatching { cashbackRepository.getCashbackSummary(userWalletId).getOrNull() }.getOrNull()
private suspend fun loadHistory(): CashbackHistory? = runSuspendCatching {
cashbackRepository.getCashbackHistory(userWalletId, CASHBACK_HISTORY_MONTHS).getOrNull()
}.getOrNull()
private suspend fun loadPromotions(): CashbackPromotions? =
runSuspendCatching { cashbackRepository.getCashbackPromotions(userWalletId).getOrNull() }.getOrNull()

View file

@ -0,0 +1,205 @@
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.foundation.shape.RoundedCornerShape
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
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.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.TangemPayCashbackHistogramUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun TangemPayCashbackHistogram(state: TangemPayCashbackHistogramUM, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(16.dp),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
)
}
Chart(bars = state.bars)
}
}
@Composable
private fun Chart(bars: ImmutableList<TangemPayCashbackHistogramUM.Bar>, modifier: Modifier = Modifier) {
val maxValue = bars.maxOfOrNull { it.amountValue }?.coerceAtLeast(0f) ?: 0f
Column(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Bottom,
) {
bars.forEach { bar ->
BarColumn(bar = bar, maxValue = maxValue, modifier = Modifier.weight(1f))
}
}
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),
) {
bars.forEach { bar ->
Text(
text = bar.month.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = monthColor(bar.style),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun BarColumn(bar: TangemPayCashbackHistogramUM.Bar, maxValue: Float, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (bar.style != Style.Regular || bar.amountValue != 0f) {
Text(
text = bar.amount.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = amountColor(bar.style),
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(barHeight(value = bar.amountValue, maxValue = maxValue))
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.background(barColor(bar.style)),
)
}
}
private fun barHeight(value: Float, maxValue: Float): Dp {
if (maxValue <= 0f) return 3.dp
val fraction = (value / maxValue).coerceIn(0f, 1f)
return maxOf(3.dp, 115.dp * fraction)
}
@Composable
private fun barColor(style: Style): Color = when (style) {
Style.Regular -> TangemTheme.colors3.bg.opaque.secondary
Style.Highlighted -> TangemTheme.colors3.bg.brand
Style.HighlightedNegative -> TangemTheme.colors3.bg.status.error
}
@Composable
private fun amountColor(style: Style): Color = when (style) {
Style.Regular -> TangemTheme.colors3.text.tertiary
else -> TangemTheme.colors3.text.primary
}
@Composable
private fun monthColor(style: Style): Color = when (style) {
Style.Regular -> TangemTheme.colors3.text.tertiary
else -> TangemTheme.colors3.text.secondary
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayCashbackHistogramPreview(
@PreviewParameter(TangemPayCashbackHistogramUMProvider::class) state: TangemPayCashbackHistogramUM,
) {
TangemThemePreviewRedesign {
TangemPayCashbackHistogram(
state = state,
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
)
}
}
@Suppress("MagicNumber")
private class TangemPayCashbackHistogramUMProvider : CollectionPreviewParameterProvider<TangemPayCashbackHistogramUM>(
collection = listOf(
// Populated
TangemPayCashbackHistogramUM(
title = stringReference("$132.15 earned in total"),
bars = persistentListOf(
bar(month = "Feb", amount = "$12.02", value = 12.02f, style = Style.Regular),
bar(month = "Mar", amount = "$44.22", value = 44.22f, style = Style.Regular),
bar(month = "Apr", amount = "$38.52", value = 38.52f, style = Style.Regular),
bar(month = "May", amount = "$26.10", value = 26.10f, style = Style.Regular),
bar(month = "Jun", amount = "$32.15", value = 32.15f, style = Style.Highlighted),
),
),
// Negative current month
TangemPayCashbackHistogramUM(
title = stringReference("$132.15 earned in total"),
bars = persistentListOf(
bar(month = "Feb", amount = "$12.02", value = 12.02f, style = Style.Regular),
bar(month = "Mar", amount = "$44.22", value = 44.22f, style = Style.Regular),
bar(month = "Apr", amount = "$38.52", value = 38.52f, style = Style.Regular),
bar(month = "May", amount = "$26.10", value = 26.10f, style = Style.Regular),
bar(month = "Jun", amount = "-$2.15", value = -2.15f, style = Style.HighlightedNegative),
),
),
// Empty
TangemPayCashbackHistogramUM(
title = stringReference("$0 earned in total"),
bars = persistentListOf(
bar(month = "Feb", amount = "$0.00", value = 0f, style = Style.Regular),
bar(month = "Mar", amount = "$0.00", value = 0f, style = Style.Regular),
bar(month = "Apr", amount = "$0.00", value = 0f, style = Style.Regular),
bar(month = "May", amount = "$0.00", value = 0f, style = Style.Regular),
bar(month = "Jun", amount = "$0.00", value = 0f, style = Style.Highlighted),
),
),
),
)
private fun bar(month: String, amount: String, value: Float, style: Style) = TangemPayCashbackHistogramUM.Bar(
month = stringReference(month),
amount = stringReference(amount),
amountValue = value,
style = style,
)

View file

@ -10,7 +10,9 @@ 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -35,10 +37,13 @@ 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.TangemPayCashbackHistogramUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackInfoTilesUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackScreenUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM
import dev.chrisbanes.haze.HazeStyle
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.R as CoreUiR
private const val GLOW_RADIUS_FACTOR = 0.585f
@ -62,20 +67,33 @@ internal fun TangemPayCashbackScreen(state: TangemPayCashbackScreenUM, modifier:
contentAlign = TangemTopNavigation.ContentAlign.Center,
onClose = state.cashback.onCloseClick,
)
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),
)
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),
)
}
}
}
}
@ -193,6 +211,7 @@ private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProv
onCloseClick = {},
),
infoTiles = previewInfoTiles(),
histogram = previewHistogram(),
),
TangemPayCashbackScreenUM(
cashback = TangemPayCashbackUM(
@ -208,6 +227,7 @@ private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProv
onCloseClick = {},
),
infoTiles = null,
histogram = null,
),
TangemPayCashbackScreenUM(
cashback = TangemPayCashbackUM(
@ -218,6 +238,7 @@ private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProv
onCloseClick = {},
),
infoTiles = null,
histogram = null,
),
),
)
@ -235,4 +256,24 @@ private fun previewInfoTiles() = TangemPayCashbackInfoTilesUM(
subtitle = stringReference("Limits and exceptions"),
onClick = {},
),
)
)
@Suppress("MagicNumber")
private fun previewHistogram(): TangemPayCashbackHistogramUM {
fun bar(month: String, amount: String, value: Float, style: Style) = TangemPayCashbackHistogramUM.Bar(
month = stringReference(month),
amount = stringReference(amount),
amountValue = value,
style = style,
)
return TangemPayCashbackHistogramUM(
title = stringReference("$132.15 earned in total"),
bars = persistentListOf(
bar(month = "Feb", amount = "$12.02", value = 12.02f, style = Style.Regular),
bar(month = "Mar", amount = "$44.22", value = 44.22f, style = Style.Regular),
bar(month = "Apr", amount = "$38.52", value = 38.52f, style = Style.Regular),
bar(month = "May", amount = "$26.10", value = 26.10f, style = Style.Regular),
bar(month = "Jun", amount = "$32.15", value = 32.15f, style = Style.Highlighted),
),
)
}

View file

@ -0,0 +1,33 @@
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 monthly earnings histogram on the Cashback screen.
*
* @property title formatted total, e.g. "$132.15 earned in total"
* @property bars one bar per calendar month, ordered oldest to newest
*/
@Immutable
internal data class TangemPayCashbackHistogramUM(
val title: TextReference,
val bars: ImmutableList<Bar>,
) {
/**
* @property amount formatted earnings for this month, e.g. "$12.02" (or "-$2.15" for a refund)
* @property amountValue numeric earnings, used only to size the bar relative to the others
* @property style visual treatment; only the current (last) month is highlighted
*/
@Immutable
data class Bar(
val month: TextReference,
val amount: TextReference,
val amountValue: Float,
val style: Style,
)
enum class Style { Regular, Highlighted, HighlightedNegative }
}

View file

@ -6,4 +6,5 @@ import androidx.compose.runtime.Immutable
internal data class TangemPayCashbackScreenUM(
val cashback: TangemPayCashbackUM,
val infoTiles: TangemPayCashbackInfoTilesUM?,
val histogram: TangemPayCashbackHistogramUM?,
)

View file

@ -0,0 +1,106 @@
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.CashbackHistory
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM
import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.collections.immutable.persistentListOf
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.math.BigDecimal
import java.util.Locale
@Suppress("MagicNumber")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayCashbackHistogramConverterTest {
private val defaultLocale = Locale.getDefault()
private val converter = TangemPayCashbackHistogramConverter()
@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 history WHEN convert THEN total sums months and last month highlighted`() {
// Arrange
val history = CashbackHistory(
currency = "USD",
months = listOf(
month(2, BigDecimal("12.02")),
month(3, BigDecimal("44.22")),
month(6, BigDecimal("32.15")),
),
)
// Act
val actual = converter.convert(history)
// Assert
val expected = TangemPayCashbackHistogramUM(
title = stringReference("$88.39 earned in total"),
bars = persistentListOf(
bar("Feb", "$12.02", 12.02f, Style.Regular),
bar("Mar", "$44.22", 44.22f, Style.Regular),
bar("Jun", "$32.15", 32.15f, Style.Highlighted),
),
)
assertThat(actual).isEqualTo(expected)
}
@Test
fun `GIVEN zero amounts WHEN convert THEN empty total and last month still highlighted`() {
// Arrange
val history = CashbackHistory(
currency = "USD",
months = listOf(month(5, BigDecimal.ZERO), month(6, BigDecimal.ZERO)),
)
// Act
val actual = converter.convert(history)
// Assert
assertThat(actual.title).isEqualTo(stringReference("$0 earned in total"))
assertThat(actual.bars.map { it.style }).containsExactly(Style.Regular, Style.Highlighted).inOrder()
assertThat(actual.bars.last().amount).isEqualTo(stringReference("$0.00"))
}
@Test
fun `GIVEN negative last month WHEN convert THEN last bar highlighted negative`() {
// Arrange
val history = CashbackHistory(
currency = "USD",
months = listOf(month(5, BigDecimal("26.10")), month(6, BigDecimal("-2.15"))),
)
// Act
val actual = converter.convert(history)
// Assert
assertThat(actual.title).isEqualTo(stringReference("$23.95 earned in total"))
assertThat(actual.bars.last().style).isEqualTo(Style.HighlightedNegative)
assertThat(actual.bars.last().amount).isEqualTo(stringReference("-$2.15"))
}
private fun bar(month: String, amount: String, value: Float, style: Style) =
TangemPayCashbackHistogramUM.Bar(stringReference(month), stringReference(amount), value, style)
private fun month(month: Int, amount: BigDecimal) =
CashbackHistory.MonthlyCashback(year = 2026, month = month, confirmedAmount = amount)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.tangempay.cashback.impl.model
import android.text.format.DateFormat
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
@ -9,18 +10,25 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.domain.pay.model.CashbackHistory
import com.tangem.domain.pay.model.CashbackPromotions
import com.tangem.domain.pay.model.CashbackSummary
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.features.tangempay.cashback.api.TangemPayCashbackComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
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
@ -40,6 +48,8 @@ internal class TangemPayCashbackModelTest {
@BeforeEach
fun setup() {
Locale.setDefault(Locale.US)
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
clearMocks(cashbackRepository, onboardingRepository)
coEvery { cashbackRepository.getCashbackSummary(any()) } returns CashbackSummary.Disabled.right()
coEvery { cashbackRepository.getCashbackPromotions(any()) } returns promotions().right()
@ -50,6 +60,7 @@ internal class TangemPayCashbackModelTest {
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
Locale.setDefault(defaultLocale)
}
@ -129,6 +140,30 @@ internal class TangemPayCashbackModelTest {
assertThat(model.accrualsSheet.value.infoRows).isNotEmpty()
}
@Test
fun `GIVEN enabled summary and history WHEN model created THEN histogram populated`() {
// Arrange
coEvery { cashbackRepository.getCashbackSummary(any()) } returns enabledSummary().right()
coEvery { cashbackRepository.getCashbackHistory(any(), any()) } returns history().right()
// Act
val model = createModel()
// Assert
assertThat(model.uiState.value.histogram).isNotNull()
assertThat(model.uiState.value.histogram?.bars).hasSize(2)
}
@Test
fun `GIVEN disabled summary WHEN model created THEN history not requested and histogram null`() {
// Act
val model = createModel()
// Assert
assertThat(model.uiState.value.histogram).isNull()
coVerify(exactly = 0) { cashbackRepository.getCashbackHistory(any(), any()) }
}
private fun createModel() = TangemPayCashbackModel(
dispatchers = TestingCoroutineDispatcherProvider(),
paramsContainer = MutableParamsContainer(TangemPayCashbackComponent.Params(userWalletId = userWalletId)),
@ -162,6 +197,31 @@ internal class TangemPayCashbackModelTest {
CashbackDocument(id = "terms", title = "Full terms of cashback program", url = "https://x/terms.pdf"),
)
private fun enabledSummary() = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = TangemPayCashback(
confirmedAmount = BigDecimal("32.15"),
pendingAmount = BigDecimal.ZERO,
currency = "USD",
payoutCurrency = "USDC",
payoutNetwork = "Polygon",
period = TangemPayCashback.Period(
year = 2026,
month = 6,
payoutStart = DateTime.parse("2026-07-02"),
payoutEnd = DateTime.parse("2026-07-05"),
),
),
)
private fun history() = CashbackHistory(
currency = "USD",
months = listOf(
CashbackHistory.MonthlyCashback(year = 2026, month = 5, confirmedAmount = BigDecimal("26.10")),
CashbackHistory.MonthlyCashback(year = 2026, month = 6, confirmedAmount = BigDecimal("32.15")),
),
)
private fun customerInfo(tierId: String, planName: String): CustomerInfo {
val currentPlan = TangemPayTariffPlan(
id = "plan-$tierId",