Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 13:19:30 +05:00
parent 76cfb2e462
commit 5ca696d677
17 changed files with 431 additions and 0 deletions

View file

@ -178,5 +178,9 @@
{
"name": "AND_16080_TRON_DEX_SWAP_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1192_TANGEM_PAY_CASHBACK_ENABLED",
"version": "undefined"
}
]

View file

@ -187,4 +187,7 @@ interface TangemPayApi {
@Body body: UpdateCardRequest,
@Path("card_id") cardId: String,
): ApiResponse<UpdateCardDisplayNameResponse>
@GET("v1/customer/cashback/summary")
suspend fun getCashbackSummary(@Header("Authorization") authHeader: String): ApiResponse<CashbackSummaryResponse>
}

View file

@ -0,0 +1,29 @@
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/summary`.
*
* When [cashbackProgramStatus] is not `enabled`, the amount/period fields may be omitted.
*/
@JsonClass(generateAdapter = true)
data class CashbackSummaryResponse(
@Json(name = "cashback_program_status") val cashbackProgramStatus: String,
@Json(name = "cashback_display_mode") val cashbackDisplayMode: String?,
@Json(name = "period") val period: Period?,
@Json(name = "confirmed_amount") val confirmedAmount: BigDecimal?,
@Json(name = "pending_amount") val pendingAmount: BigDecimal?,
@Json(name = "currency") val currency: String?,
) {
@JsonClass(generateAdapter = true)
data class Period(
@Json(name = "year") val year: Int,
@Json(name = "month") val month: Int,
@Json(name = "payout_start_date") val payoutStartDate: String?,
@Json(name = "payout_end_date") val payoutEndDate: String?,
)
}

View file

@ -301,6 +301,11 @@ internal interface TangemPayDataModule {
return GetCustomerOffersUseCase(customerOffersRepository)
}
@Provides
fun provideGetCashbackSummaryUseCase(cashbackRepository: CashbackRepository): GetCashbackSummaryUseCase {
return GetCashbackSummaryUseCase(cashbackRepository)
}
@Provides
fun provideCheckOrderConflictUseCase(
customerOrderRepository: CustomerOrderRepository,

View file

@ -0,0 +1,22 @@
package com.tangem.data.pay.repository
import arrow.core.Either
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.CashbackSummary
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
internal class DefaultCashbackRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : CashbackRepository {
override suspend fun getCashbackSummary(userWalletId: UserWalletId): Either<VisaApiError, CashbackSummary> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCashbackSummary(authHeader = authHeader)
}.map(CashbackSummaryConverter::convert)
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.CashbackSummaryResponse
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackProgramStatus
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
import java.math.BigDecimal
/** Maps [CashbackSummaryResponse] (BFF) to the domain [CashbackSummary]. */
internal object CashbackSummaryConverter : Converter<CashbackSummaryResponse, CashbackSummary> {
override fun convert(value: CashbackSummaryResponse): CashbackSummary {
return when (CashbackProgramStatus.fromString(value.cashbackProgramStatus)) {
CashbackProgramStatus.ENABLED -> toEnabled(value)
CashbackProgramStatus.DEACTIVATED -> CashbackSummary.Deactivated
CashbackProgramStatus.DISABLED -> CashbackSummary.Disabled
CashbackProgramStatus.UNKNOWN -> CashbackSummary.Unknown
}
}
private fun toEnabled(value: CashbackSummaryResponse): CashbackSummary {
val period = value.period ?: return CashbackSummary.Unknown
val payoutStart = period.payoutStartDate?.let(DateTime::parse) ?: return CashbackSummary.Unknown
val payoutEnd = period.payoutEndDate?.let(DateTime::parse) ?: return CashbackSummary.Unknown
return CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.fromString(value.cashbackDisplayMode),
cashback = TangemPayCashback(
confirmedAmount = value.confirmedAmount ?: BigDecimal.ZERO,
currency = value.currency.orEmpty(),
period = TangemPayCashback.Period(
year = period.year,
month = period.month,
payoutStart = payoutStart,
payoutEnd = payoutEnd,
),
),
pendingAmount = value.pendingAmount ?: BigDecimal.ZERO,
)
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.repository.MockAwareCashbackRepository
import com.tangem.data.pay.repository.MockAwareOnboardingRepository
import com.tangem.data.pay.repository.MockAwareTangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import dagger.Binds
@ -21,4 +23,8 @@ internal interface TangemPayDataMockedModule {
@Binds
@Singleton
fun bindCardDetailsRepository(repository: MockAwareTangemPayCardDetailsRepository): TangemPayCardDetailsRepository
@Binds
@Singleton
fun bindCashbackRepository(repository: MockAwareCashbackRepository): CashbackRepository
}

View file

@ -0,0 +1,52 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.right
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.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.visa.error.VisaApiError
import org.joda.time.DateTime
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
/** In MOCK env returns a canned cashback summary; otherwise delegates to [DefaultCashbackRepository]. */
@Singleton
internal class MockAwareCashbackRepository @Inject constructor(
private val real: DefaultCashbackRepository,
private val apiConfigsManager: ApiConfigsManager,
) : CashbackRepository {
private val isMockMode: Boolean
get() = apiConfigsManager
.getEnvironmentConfig(ApiConfig.ID.TangemPay)
.environment == ApiEnvironment.MOCK
override suspend fun getCashbackSummary(userWalletId: UserWalletId): Either<VisaApiError, CashbackSummary> {
if (isMockMode) return MOCK_SUMMARY.right()
return real.getCashbackSummary(userWalletId)
}
private companion object {
val MOCK_SUMMARY = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = TangemPayCashback(
confirmedAmount = BigDecimal("22.54"),
currency = "USD",
period = TangemPayCashback.Period(
year = 2026,
month = 6,
payoutStart = DateTime.parse("2026-07-02"),
payoutEnd = DateTime.parse("2026-07-05"),
),
),
pendingAmount = BigDecimal("13.65"),
)
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.repository.DefaultCashbackRepository
import com.tangem.data.pay.repository.DefaultOnboardingRepository
import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import dagger.Binds
@ -21,4 +23,8 @@ internal interface TangemPayDataProductionModule {
@Binds
@Singleton
fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository
@Binds
@Singleton
fun bindCashbackRepository(repository: DefaultCashbackRepository): CashbackRepository
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.pay.util
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CashbackSummaryResponse
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.model.TangemPayCashback
import org.joda.time.DateTime
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 CashbackSummaryConverterTest {
@ParameterizedTest
@MethodSource("provideTestModels")
fun convert(model: ConvertModel) {
// Act
val actual = CashbackSummaryConverter.convert(model.response)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
@Suppress("LongMethod")
private fun provideTestModels() = listOf(
ConvertModel(
name = "enabled + full -> Enabled(FULL)",
response = createResponse(status = "enabled", displayMode = "full"),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = expectedCashback(),
pendingAmount = BigDecimal("13.65"),
),
),
ConvertModel(
name = "enabled + alt_block -> Enabled(ALT_BLOCK)",
response = createResponse(status = "enabled", displayMode = "alt_block"),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.ALT_BLOCK,
cashback = expectedCashback(),
pendingAmount = BigDecimal("13.65"),
),
),
ConvertModel(
name = "enabled + missing display mode -> Enabled(FULL)",
response = createResponse(status = "enabled", displayMode = null),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = expectedCashback(),
pendingAmount = BigDecimal("13.65"),
),
),
ConvertModel(
name = "enabled + zero amounts -> Enabled with zeros",
response = createResponse(
status = "enabled",
displayMode = "full",
confirmedAmount = null,
pendingAmount = null,
),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = expectedCashback(confirmedAmount = BigDecimal.ZERO),
pendingAmount = BigDecimal.ZERO,
),
),
ConvertModel(
name = "enabled but missing period -> Unknown",
response = createResponse(status = "enabled", period = null),
expected = CashbackSummary.Unknown,
),
ConvertModel(
name = "deactivated -> Deactivated",
response = createResponse(status = "deactivated"),
expected = CashbackSummary.Deactivated,
),
ConvertModel(
name = "disabled -> Disabled",
response = createResponse(status = "disabled"),
expected = CashbackSummary.Disabled,
),
ConvertModel(
name = "unavailable -> Disabled",
response = createResponse(status = "unavailable"),
expected = CashbackSummary.Disabled,
),
ConvertModel(
name = "unrecognized status -> Unknown",
response = createResponse(status = "something_new"),
expected = CashbackSummary.Unknown,
),
)
internal data class ConvertModel(
val name: String,
val response: CashbackSummaryResponse,
val expected: CashbackSummary,
) {
override fun toString(): String = name
}
private companion object {
fun createResponse(
status: String = "enabled",
displayMode: String? = "full",
period: CashbackSummaryResponse.Period? = CashbackSummaryResponse.Period(
year = 2026,
month = 6,
payoutStartDate = "2026-07-02",
payoutEndDate = "2026-07-05",
),
confirmedAmount: BigDecimal? = BigDecimal("22.54"),
pendingAmount: BigDecimal? = BigDecimal("13.65"),
currency: String? = "USD",
) = CashbackSummaryResponse(
cashbackProgramStatus = status,
cashbackDisplayMode = displayMode,
period = period,
confirmedAmount = confirmedAmount,
pendingAmount = pendingAmount,
currency = currency,
)
fun expectedCashback(confirmedAmount: BigDecimal = BigDecimal("22.54")) = TangemPayCashback(
confirmedAmount = confirmedAmount,
currency = "USD",
period = TangemPayCashback.Period(
year = 2026,
month = 6,
payoutStart = DateTime.parse("2026-07-02"),
payoutEnd = DateTime.parse("2026-07-05"),
),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.pay.model
/**
* Cashback block layout variant, from `cashback_display_mode`. Present only when the program is enabled.
*
* Absent or unrecognized values fall back to [FULL] (see summary display-mode logic).
*/
enum class CashbackDisplayMode {
/** Standard cashback block. */
FULL,
/** Alternative EU/EEA block. */
ALT_BLOCK,
;
companion object {
fun fromString(value: String?): CashbackDisplayMode = when (value?.lowercase()) {
"alt_block" -> ALT_BLOCK
else -> FULL
}
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.domain.pay.model
/**
* Cashback program availability for the customer, from `cashback_program_status`
* in `GET /v1/customer/cashback/summary`.
*
* Unknown wire values resolve to [UNKNOWN].
*/
enum class CashbackProgramStatus {
/** Cashback is active. */
ENABLED,
/** Customer blocked due to fraud behavior; client shows the "Cashback deactivated" banner. */
DEACTIVATED,
/** Cashback not available for this cohort/region; client silently hides all cashback UI. */
DISABLED,
/** Unrecognized status. */
UNKNOWN,
;
companion object {
fun fromString(value: String?): CashbackProgramStatus = when (value?.lowercase()) {
"enabled" -> ENABLED
"deactivated" -> DEACTIVATED
"disabled", "unavailable" -> DISABLED
else -> UNKNOWN
}
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.pay.model
import java.math.BigDecimal
/**
* Customer cashback summary from `GET /v1/customer/cashback/summary`.
*
* Drives the cashback widget on the Payment account screen and the Cashback screen header.
*/
sealed interface CashbackSummary {
/** Cashback is active for the customer. */
data class Enabled(
val displayMode: CashbackDisplayMode,
val cashback: TangemPayCashback,
val pendingAmount: BigDecimal,
) : CashbackSummary
/** Customer blocked due to fraud; client shows the "Cashback deactivated" banner. */
data object Deactivated : CashbackSummary
/** Cashback not available for this cohort/region; client hides all cashback UI. */
data object Disabled : CashbackSummary
/** Unrecognized program status; treated as no cashback UI. */
data object Unknown : CashbackSummary
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.visa.error.VisaApiError
/**
* Repository for the cashback endpoints (`GET /v1/customer/cashback/...`).
*/
interface CashbackRepository {
/** Loads the cashback summary for the customer of [userWalletId]. */
suspend fun getCashbackSummary(userWalletId: UserWalletId): Either<VisaApiError, CashbackSummary>
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.repository.CashbackRepository
import com.tangem.domain.visa.error.VisaApiError
/**
* Loads the cashback summary from `GET /v1/customer/cashback/summary`.
*
* Used by the Payment account cashback widget and the Cashback screen header.
*/
class GetCashbackSummaryUseCase(
private val cashbackRepository: CashbackRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, CashbackSummary> {
return cashbackRepository.getCashbackSummary(userWalletId)
}
}

View file

@ -6,4 +6,5 @@ interface TangemPayFeatureToggles {
val isRemoveAccountEnabled: Boolean
val isMultipleCardsEnabled: Boolean
val isTiersPlusPlanEnabled: Boolean
val isCashbackEnabled: Boolean
}

View file

@ -21,4 +21,7 @@ internal class DefaultTangemPayFeatureToggles(
override val isTiersPlusPlanEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_16041_VISA_TIERS_PLUS_PLAN)
override val isCashbackEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1192_TANGEM_PAY_CASHBACK_ENABLED)
}