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

@ -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,
)
}