Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 14:08:01 +03:00
commit 06a915a0f4
2186 changed files with 101417 additions and 36593 deletions

View file

@ -23,63 +23,87 @@ android {
}
dependencies {
/** Project - Data */
implementation(projects.core.analytics)
implementation(projects.core.datasource)
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.core.security)
implementation(projects.data.common)
implementation(projects.data.wallets)
/** Project - Domain */
implementation(projects.domain.visa)
implementation(projects.domain.virtualAccount)
implementation(projects.domain.card)
implementation(projects.domain.wallets)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.quotes)
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
implementation(projects.features.virtualAccounts.details.api)
/** Project - Utils */
implementation(projects.core.utils)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
/** Project - Libs */
implementation(projects.libs.visa)
/** Libs - Other */
implementation(deps.androidx.datastore)
// region Kotlin
implementation(deps.kotlin.coroutines)
// endregion
// region Other libraries
api(deps.moshi)
implementation(deps.androidx.datastore)
implementation(deps.androidx.paging.runtime)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi.kotlin)
implementation(deps.okio)
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
/** Libs - Tangem */
// region Libs - Tangem
api(tangemDeps.hot.core)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
implementation(projects.libs.tangemSdkApi)
// AndroidSecureStorageV2 for TangemPay secure token storage (see com.tangem.data.pay.store).
implementation(tangemDeps.card.android) {
exclude(module = "joda-time")
}
// endregion
/** DI */
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
/** Test */
// region Project - Core
api(projects.core.analytics)
api(projects.core.datasource)
api(projects.core.security)
api(projects.core.utils)
implementation(projects.core.analytics.models)
implementation(projects.core.error)
implementation(projects.core.error.ext)
implementation(projects.core.pagination)
// endregion
// region Project - Data
api(projects.data.common)
implementation(projects.data.wallets)
// endregion
// region Project - Domain
api(projects.domain.common)
api(projects.domain.core)
api(projects.domain.networks)
api(projects.domain.quotes)
api(projects.domain.virtualAccount)
api(projects.domain.visa)
api(projects.domain.wallets)
implementation(projects.domain.card)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.walletManager)
runtimeOnly(projects.domain.tokens)
// endregion
// region Project - Domain models
api(projects.domain.visa.models)
implementation(projects.domain.appCurrency.models)
// endregion
// region Project - Features
api(projects.features.swap.domain)
api(projects.features.virtualAccounts.details.api)
api(projects.features.tangempay.details.api)
// endregion
// region Project - Libs
api(projects.libs.blockchainSdk)
api(projects.libs.tangemSdkApi)
implementation(projects.libs.visa)
// endregion
// region Test
testImplementation(projects.test.core)
// endregion
}

View file

@ -143,6 +143,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
private fun TangemPayEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) {
TangemPayEntryPoint.BANNER -> TangemPayEligibilityType.BANNER
TangemPayEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS
TangemPayEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK
}
private data class UserWalletData(

View file

@ -0,0 +1,18 @@
package com.tangem.data.pay
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
internal class TangemPayUserWalletDataCleaner @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
) : UserWalletDataCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
paymentAccountStatusesStore.remove(userWalletIds)
txHistoryItemsStore.remove(userWalletIds.map { it.stringValue })
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -51,6 +52,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
adminDailyLimit = card.limit?.adminCardLimit?.amount,
frozenState = card.frozenState.toString(),
lastDigits = card.lastDigits,
images = card.images.map { image ->
PaymentAccountStatusValueDM.ImageDM(
type = image.type.name,
url = image.url,
)
},
state = card.state.toString(),
)
},
@ -68,6 +75,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.AwaitingPlanSelection,
is PaymentAccountStatusValue.Inactive,
is PaymentAccountStatusValue.Error.ExposedDevice,
is PaymentAccountStatusValue.Error.Unavailable,
is PaymentAccountStatusValue.Error.NotSynced,
@ -90,11 +99,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
source = StatusSource.CACHE,
customerId = value.customerId,
depositAddress = value.depositAddress,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
balance = value.getBalance(),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
@ -114,11 +119,13 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
),
frozenState = TangemPayCardFrozenState.fromString(card.frozenState),
lastDigits = card.lastDigits,
images = card.getImages(),
state = TangemPayCardState.fromString(card.state),
)
},
error = null,
virtualAccount = null,
tariffPlan = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,
@ -158,6 +165,14 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
}
private fun PaymentAccountStatusValueDM.ActiveAccount.getBalance(): PaymentAccountStatusValue.Balance {
return PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = cryptoBalance.toDomain(),
availableForWithdrawal = availableForWithdrawal,
)
}
private fun PaymentAccountStatusValueDM.FiatBalanceDM.toDomain(): PaymentAccountStatusValue.FiatBalance {
return PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
@ -174,4 +189,13 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
balance = balance,
)
}
private fun PaymentAccountStatusValueDM.TangemPayCard.getImages(): List<TangemPayTariffPlan.Image> {
return images.map { image ->
TangemPayTariffPlan.Image(
type = TangemPayTariffPlan.Image.Type.fromString(image.type),
url = image.url,
)
}
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.data.pay.converter
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
import java.util.Locale
internal object TangemPayTariffPlanConverter {
fun convert(value: CustomerMeResponse.TariffPlan?): TangemPayTariffPlan? {
val id = value?.id ?: return null
val name = value.name ?: return null
val programName = value.programName ?: return null
// We cannot base our logic on knowledge what exact tier type is it.
// Use it only as identifier to get data from other responses
val tierId = value.type ?: return null
// Basic tier is a default tier. We can base some features on it.
// Other tiers are adjusted from admin panel. It is not guaranteed to have it in future
val isBasicTier = tierId.uppercase(Locale.US) == "BASIC"
return TangemPayTariffPlan(
id = id,
tierId = tierId,
isBasicTier = isBasicTier,
name = name,
programName = programName,
descriptionItems = value.descriptionItems.orEmpty().mapNotNull(::convertDescriptionItem),
images = value.images.orEmpty().mapNotNull(::convertImage),
fees = value.fees.orEmpty().mapNotNull(::convertFee),
)
}
private fun convertFee(fee: CustomerMeResponse.Fee): TangemPayTariffPlan.Fee? {
val amount = fee.amount ?: return null
return TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.fromString(fee.type),
amount = amount,
currency = fee.currency.orEmpty(),
description = fee.description.orEmpty(),
period = fee.period?.let(TangemPayTariffPlan.Fee.Period::fromString),
)
}
private fun convertDescriptionItem(item: CustomerMeResponse.DescriptionItem): TangemPayTariffPlan.DescriptionItem? {
val title = item.title ?: return null
return TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.fromString(item.type),
order = item.order ?: 0,
title = title,
body = item.body.orEmpty(),
)
}
private fun convertImage(image: CustomerMeResponse.Image): TangemPayTariffPlan.Image? {
val url = image.url ?: return null
return TangemPayTariffPlan.Image(
type = TangemPayTariffPlan.Image.Type.fromString(image.type),
url = url,
)
}
}

View file

@ -6,6 +6,7 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.TangemPayUserWalletDataCleaner
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
@ -21,6 +22,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -41,6 +43,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module
@ -117,8 +120,59 @@ internal interface TangemPayDataModule {
@Singleton
fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher
@Binds
@IntoSet
fun bindTangemPayUserWalletDataCleaner(impl: TangemPayUserWalletDataCleaner): UserWalletDataCleaner
@Binds
@Singleton
fun bindTariffPlanTransitionsRepository(
repository: DefaultTariffPlanTransitionsRepository,
): TangemPayTariffPlanTransitionsRepository
@Suppress("TooManyFunctions")
companion object {
@Provides
fun provideGetTangemPayTariffPlanTransitionsUseCase(
repository: TangemPayTariffPlanTransitionsRepository,
): GetTangemPayTariffPlanTransitionsUseCase {
return GetTangemPayTariffPlanTransitionsUseCase(repository)
}
@Provides
fun provideSetTariffPlanPendingTransitionUseCase(
repository: TangemPayTariffPlanTransitionsRepository,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): SetTariffPlanPendingTransitionUseCase {
return SetTariffPlanPendingTransitionUseCase(
repository = repository,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideCancelTariffPlanPendingTransitionUseCase(
repository: TangemPayTariffPlanTransitionsRepository,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CancelTariffPlanPendingTransitionUseCase {
return CancelTariffPlanPendingTransitionUseCase(
repository = repository,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideGetTangemPayTariffPlanStateUseCase(
customerOrderRepository: CustomerOrderRepository,
getTangemPayTariffPlanTransitionsUseCase: GetTangemPayTariffPlanTransitionsUseCase,
): GetTangemPayTariffPlanStateUseCase {
return GetTangemPayTariffPlanStateUseCase(
customerOrderRepository = customerOrderRepository,
getTariffPlanTransitions = getTangemPayTariffPlanTransitionsUseCase,
)
}
@Provides
@Singleton
fun providePaymentAccountStatusesStore(
@ -247,6 +301,25 @@ internal interface TangemPayDataModule {
return GetCustomerOffersUseCase(customerOffersRepository)
}
@Provides
fun provideGetCashbackSummaryUseCase(cashbackRepository: CashbackRepository): GetCashbackSummaryUseCase {
return GetCashbackSummaryUseCase(cashbackRepository)
}
@Provides
fun provideGetCashbackDeactivationDismissedUseCase(
cashbackRepository: CashbackRepository,
): GetCashbackDeactivationDismissedUseCase {
return GetCashbackDeactivationDismissedUseCase(cashbackRepository)
}
@Provides
fun provideSetCashbackDeactivationDismissedUseCase(
cashbackRepository: CashbackRepository,
): SetCashbackDeactivationDismissedUseCase {
return SetCashbackDeactivationDismissedUseCase(cashbackRepository)
}
@Provides
fun provideCheckOrderConflictUseCase(
customerOrderRepository: CustomerOrderRepository,
@ -308,5 +381,73 @@ internal interface TangemPayDataModule {
appCoroutineScope = appCoroutineScope,
)
}
@Provides
fun provideCancelTangemPayOrderUseCase(
customerOrderRepository: CustomerOrderRepository,
issueCardRepository: TangemPayIssueCardRepository,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
): CancelTangemPayOrderUseCase {
return CancelTangemPayOrderUseCase(
customerOrderRepository = customerOrderRepository,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
)
}
@Provides
fun provideCreateTariffPlanTransitionOrderUseCase(
customerOrderRepository: CustomerOrderRepository,
issueCardRepository: TangemPayIssueCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CreateTariffPlanTransitionOrderUseCase {
return CreateTariffPlanTransitionOrderUseCase(
customerOrderRepository = customerOrderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = appCoroutineScope,
)
}
@Provides
fun provideSubmitTariffTransitionUseCase(
createTransitionOrder: CreateTariffPlanTransitionOrderUseCase,
setPendingTransition: SetTariffPlanPendingTransitionUseCase,
): SubmitTariffTransitionUseCase {
return SubmitTariffTransitionUseCase(
createTransitionOrder = createTransitionOrder,
setPendingTransition = setPendingTransition,
)
}
@Provides
fun provideCancelTariffTransitionUseCase(
cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase,
getTariffTransitionUseCase: GetTangemPayTariffPlanTransitionsUseCase,
submitTariffTransitionUseCase: SubmitTariffTransitionUseCase,
getCurrentTariffUseCase: GetCurrentTariffUseCase,
): CancelTariffTransitionUseCase {
return CancelTariffTransitionUseCase(
cancelTangemPayOrderUseCase = cancelTangemPayOrderUseCase,
getTariffTransitionUseCase = getTariffTransitionUseCase,
submitTariffTransitionUseCase = submitTariffTransitionUseCase,
getCurrentTariffUseCase = getCurrentTariffUseCase,
)
}
@Provides
fun provideGetCurrentTariffUseCase(
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
): GetCurrentTariffUseCase {
return GetCurrentTariffUseCase(
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
}
}
}

View file

@ -18,9 +18,11 @@ import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
@ -63,6 +65,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -176,17 +180,63 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId)
if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(account.userWalletId)
.onLeft { TangemLogger.withTag(TAG).e("createOrder failed: $it") }
}
status
resolveFinalStatus(
account = account,
customerInfo = customerInfo,
status = customerInfo.mapToPaymentAccountStatus(account.userWalletId),
)
},
)
}
private suspend fun resolveFinalStatus(
account: Account.Payment,
customerInfo: CustomerInfo,
status: PaymentAccountStatusValue,
): PaymentAccountStatusValue {
val isIssuing = status is PaymentAccountStatusValue.IssuingCard
val isApproved = customerInfo.kycStatus == KycStatus.APPROVED
val userWalletId = account.userWalletId
val tariffPlan = customerInfo.tariffPlan
if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled) {
if (isIssuing && isApproved) {
// If order id wasn't saved -> start order creation and get customer info
onboardingRepository.createOrder(userWalletId)
.onLeft { logger.e("createOrder failed: $it") }
}
return status
}
if (!isIssuing || !isApproved) {
return status
}
if (tariffPlan == null) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
val hasActiveIssueOrder = issueCardRepository.getIssueOrderIds(userWalletId).isNotEmpty()
return if (hasActiveIssueOrder) {
PaymentAccountStatusValue.Inactive(
source = StatusSource.ACTUAL,
tariffPlan = getTangemPayTariffPlanStateUseCase(
userWalletId = userWalletId,
tariff = tariffPlan,
),
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.ZERO,
currency = tariffPlan.plan.feeCurrencyOrDefault(),
),
)
} else {
PaymentAccountStatusValue.AwaitingPlanSelection(
source = StatusSource.ACTUAL,
tariffPlan = tariffPlan,
)
}
}
private suspend fun proceedWithOrderId(account: Account.Payment, orderId: String): PaymentAccountStatusValue {
// Step 1: Check KYC status first
val customerInfo = onboardingRepository.getCustomerInfo(account.userWalletId).fold(
@ -288,24 +338,26 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val customerId = customerId
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
val fiatBalance = fiatBalance
val cryptoBalance = cryptoBalance
val hasCardData = cards.isNotEmpty() && productInstances.isNotEmpty()
val isTiersPlusPlanEnabled = tangemPayFeatureToggles.isTiersPlusPlanEnabled
return when {
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
customerId.isNullOrEmpty() -> PaymentAccountStatusValue.IssuingCard(
source = StatusSource.ACTUAL,
)
kycStatus != KycStatus.APPROVED -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = customerId,
)
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) ->
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
customerId = customerId,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
@ -315,15 +367,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
fiatRate = quotesData?.fiatRate,
error = null,
)
}
cards.isNotEmpty() && productInstances.isNotEmpty() &&
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
fiatBalance != null && cryptoBalance != null && (hasCardData || isTiersPlusPlanEnabled) ->
convertToContentState(
userWalletId = userWalletId,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = customerId,
)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
}
@ -362,11 +413,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
productInstance.frozenState
},
lastDigits = cardInfo.lastFourDigits,
images = cardInfo.images,
state = getCardState(cardId, userWalletId),
)
}
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
if (!tangemPayFeatureToggles.isTiersPlusPlanEnabled && tangemPayCards.isEmpty()) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
// Additional-card issuance: the backend omits the new card until it is provisioned, so surface a
// placeholder for every locally tracked in-flight issuance order alongside the real cards.
@ -377,6 +431,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
// the previously shown order and append newly seen cards at the end.
val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId))
val allCards = orderedCards + issuingCards
if (allCards.isEmpty()) {
return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
val virtualAccount = resolveVirtualAccountOnramp(userWalletId)
return PaymentAccountStatusValue.Loaded(
@ -385,7 +445,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = orderedCards + issuingCards,
cards = allCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
@ -393,6 +453,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
),
error = null,
virtualAccount = virtualAccount,
tariffPlan = tariffPlan?.let { tariff ->
getTangemPayTariffPlanStateUseCase(
userWalletId = userWalletId,
tariff = tariff,
)
},
)
}
@ -494,7 +560,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return if (closingOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
closeCardRepository.setCloseOrderId(cardId, null)
closeCardRepository.removeCloseOrderId(cardId)
TangemPayCardState.Active
} else {
TangemPayCardState.Closing
@ -502,6 +568,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
} else if (reissueOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
reissueCardRepository.removeReissueOrderId(cardId)
TangemPayCardState.Active
} else {
TangemPayCardState.Reissuing
@ -539,6 +606,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
limit = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "",
images = emptyList(),
state = TangemPayCardState.Issuing,
)

View file

@ -0,0 +1,63 @@
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
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
internal class DefaultCashbackRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val storage: TangemPayStorage,
) : CashbackRepository {
override suspend fun getCashbackSummary(userWalletId: UserWalletId): Either<VisaApiError, CashbackSummary> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCashbackSummary(authHeader = authHeader)
}.map(CashbackSummaryConverter::convert)
}
override suspend fun getCashbackPromotions(userWalletId: UserWalletId): Either<VisaApiError, CashbackPromotions> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCashbackPromotions(authHeader = authHeader)
}.map(CashbackPromotionsConverter::convert)
}
override suspend fun getCashbackAccrualDocs(
userWalletId: UserWalletId,
): Either<VisaApiError, List<CashbackDocument>> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCashbackAccrualDocs(authHeader = authHeader)
}.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)
}
override suspend fun setDeactivationBannerDismissed(userWalletId: UserWalletId) {
val customerWalletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
storage.storeCashbackDeactivationDismissed(customerWalletAddress = customerWalletAddress, isDismissed = true)
}
}

View file

@ -37,14 +37,21 @@ internal class DefaultCloseCardRepository @Inject constructor(
)
}
override suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit> =
override suspend fun storeCloseOrderId(cardId: String, orderId: String): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayCloseCardStore.setCloseOrderId(cardId, orderId)
tangemPayCloseCardStore.storeCloseOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun removeCloseOrderId(cardId: String): Either<UniversalError, Unit> = runSuspendCatching {
tangemPayCloseCardStore.removeCloseOrderId(cardId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> =
either {
runSuspendCatching { tangemPayCloseCardStore.getOrderId(cardId) }.getOrNull()

View file

@ -1,10 +1,8 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.right
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.CustomerOffersResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Offer
import com.tangem.domain.pay.model.OrderType
@ -18,18 +16,11 @@ internal class DefaultCustomerOffersRepository @Inject constructor(
private val requestHelper: TangemPayRequestPerformer,
) : CustomerOffersRepository {
private val offersCache = RuntimeSharedStore<Map<String, List<Offer>>>()
override suspend fun getOffers(userWalletId: UserWalletId): Either<VisaApiError, List<Offer>> {
val key = userWalletId.stringValue
offersCache.getSyncOrNull()?.get(key)?.let { return it.right() }
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getCustomerOffers(authHeader = authHeader)
}.map { response ->
response.result.map { it.toDomain() }
}.onRight { offers ->
offersCache.update(default = emptyMap()) { it + (key to offers) }
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.data.pay.util.OrderConverter
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderData
@ -53,8 +54,10 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
override suspend fun createOrder(
userWalletId: UserWalletId,
type: OrderType,
specificationName: String,
specificationName: String?,
idempotencyKey: String,
targetTariffPlanId: String?,
transitionType: TangemPayTariffPlanTransition.Type?,
): Either<VisaApiError, Order> {
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
return requestHelper.performRequest(userWalletId) { authHeader ->
@ -65,6 +68,8 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
customerWalletAddress = walletAddress,
specificationName = specificationName,
type = type.wireValue,
targetTariffPlanId = targetTariffPlanId,
tariffPlanTransitionType = transitionType?.name,
),
idempotencyKey = idempotencyKey,
),
@ -74,4 +79,10 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
OrderConverter.convert(result)
}
}
override suspend fun cancelOrder(userWalletId: UserWalletId, orderId: String): Either<VisaApiError, Unit> {
return requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.cancelOrder(authHeader = authHeader, orderId = orderId)
}.map {}
}
}

View file

@ -16,7 +16,8 @@ import com.tangem.datasource.api.pay.models.request.VirtualAccountOrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
@ -28,6 +29,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
@ -50,6 +52,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val paymentAccountStatusStore: PaymentAccountStatusesStore,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
) : OnboardingRepository {
// Save data for a session
@ -159,7 +162,11 @@ internal class DefaultOnboardingRepository @Inject constructor(
val walletAddress = requestHelper.getCustomerWalletAddress(userWalletId)
requestHelper.performRequest(userWalletId) { authHeader ->
val data = OrderRequest.Data(customerWalletAddress = walletAddress)
val data = OrderRequest.Data(
customerWalletAddress = walletAddress,
specificationName = "SP_000004",
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC.wireValue,
)
tangemPayApi.createOrder(
authHeader = authHeader,
body = OrderRequest(data = data, idempotencyKey = UUID.randomUUID().toString()),
@ -311,6 +318,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}.map {
val address = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearAll(userWalletId = userWalletId, customerWalletAddress = address)
txHistoryItemsStore.remove(userWalletId.stringValue)
setHideMainOnboardingBanner(userWalletId)
}
}

View file

@ -72,6 +72,13 @@ internal class DefaultReissueCardRepository @Inject constructor(
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun removeReissueOrderId(cardId: String): Either<UniversalError, Unit> = runSuspendCatching {
tangemPayReissueCardStore.removeReissueOrderId(cardId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getReissueOrderId(
userWalletId: UserWalletId,
cardId: String,

View file

@ -20,7 +20,7 @@ import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus

View file

@ -1,5 +1,6 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.visa.utils.TangemPayTxHistoryItemConverter
@ -11,6 +12,7 @@ import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
@ -18,6 +20,7 @@ import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.fetcher.CursorBatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
private const val INITIAL_CURSOR = "initial_cursor_key"
@ -76,22 +79,39 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
cursor: String?,
limit: Int,
): List<TangemPayTxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getCacheKey(userWalletId = userWalletId, cursor = cursor),
skipCache = config.shouldRefresh,
block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) },
)
val storeKey = userWalletId.stringValue
val storeCursor = cursor ?: INITIAL_CURSOR
return txHistoryItemsStore.getSyncOrNull(
key = userWalletId.stringValue,
cursor = cursor ?: INITIAL_CURSOR,
).orEmpty()
val fetchResult = runSuspendCatching {
cacheRegistry.invokeOnExpire(
key = getCacheKey(userWalletId = userWalletId, cursor = cursor),
skipCache = config.shouldRefresh,
block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) },
)
}
val storedPage = txHistoryItemsStore.getSyncOrNull(key = storeKey, cursor = storeCursor)
return fetchResult.fold(
onSuccess = { storedPage.orEmpty() },
onFailure = { e -> storedPage?.takeIf { it.isNotEmpty() } ?: throw e },
)
}
private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String {
return "tangem_pay_tx_history_${userWalletId.stringValue}_${cursor ?: INITIAL_CURSOR}"
}
override suspend fun getTransaction(
userWalletId: UserWalletId,
transactionId: String,
): Either<VisaApiError, TangemPayTxHistoryItem?> {
return requestPerformer.performRequest(userWalletId = userWalletId) { authHeader ->
visaApi.getCustomerTransaction(authHeader = authHeader, transactionId = transactionId)
}.map { response ->
txHistoryItemConverter.convert(response.result)
}
}
private suspend fun fetch(userWalletId: UserWalletId, cursor: String?, pageSize: Int) {
requestPerformer.performRequest(userWalletId = userWalletId) { authHeader ->
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)

View file

@ -9,7 +9,7 @@ import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.WithdrawDataRequest
import com.tangem.datasource.api.pay.models.request.WithdrawRequest
import com.tangem.datasource.api.pay.models.response.WithdrawResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId

View file

@ -0,0 +1,52 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.data.pay.converter.TangemPayTariffPlanConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.SetPendingTariffPlanTransitionRequest
import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionResponse
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.repository.TangemPayTariffPlanTransitionsRepository
import com.tangem.domain.visa.error.VisaApiError
import javax.inject.Inject
internal class DefaultTariffPlanTransitionsRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
) : TangemPayTariffPlanTransitionsRepository {
override suspend fun getTransitions(
userWalletId: UserWalletId,
): Either<VisaApiError, List<TangemPayTariffPlanTransition>> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.getTariffPlanTransitions(authHeader = authHeader)
}.bind()
response.result.orEmpty().mapNotNull { it.toDomain() }
}
override suspend fun setPendingTransition(
userWalletId: UserWalletId,
pendingTariffPlanId: String,
): Either<VisaApiError, Unit> = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.setPendingTariffPlanTransition(
authHeader = authHeader,
body = SetPendingTariffPlanTransitionRequest(pendingTariffPlanId = pendingTariffPlanId),
)
}.map {}
override suspend fun cancelPendingTransition(userWalletId: UserWalletId): Either<VisaApiError, Unit> =
requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.cancelPendingTariffPlanTransition(authHeader = authHeader)
}.map {}
private fun TariffPlanTransitionResponse.toDomain(): TangemPayTariffPlanTransition? {
val plan = TangemPayTariffPlanConverter.convert(tariffPlan) ?: return null
return TangemPayTariffPlanTransition(
type = TangemPayTariffPlanTransition.Type.fromString(type),
plan = plan,
)
}
}

View file

@ -11,7 +11,7 @@ import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest
import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayAuthTokens

View file

@ -0,0 +1,317 @@
package com.tangem.data.pay.store
import android.content.Context
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.data.pay.entity.WithdrawStoreData
import com.tangem.data.pay.util.WithdrawStateConverter
import com.tangem.data.pay.util.WithdrawStoreDataConverter
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val AUTH_TOKENS_DEFAULT_KEY = "tangem_pay_default_key"
private const val WITHDRAW_ORDER_ID_KEY = "tangem_pay_withdraw_order_id_key"
@Suppress("TooManyFunctions")
@Singleton
internal class DefaultTangemPayStorage @Inject constructor(
@ApplicationContext applicationContext: Context,
@NetworkMoshi moshi: Moshi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
) : TangemPayStorage {
private val secureStorage by lazy {
AndroidSecureStorageV2(
appContext = applicationContext,
useStrongBox = false,
name = "tangem_pay_storage",
)
}
private val tokensAdapter by lazy { moshi.adapter(TangemPayAuthTokens::class.java) }
private val withdrawStoreDataConverter by lazy { WithdrawStoreDataConverter() }
private val withdrawStateConverter by lazy { WithdrawStateConverter() }
private val listType by lazy {
Types.newParameterizedType(List::class.java, WithdrawStoreData::class.java)
}
private val mapType by lazy {
Types.newParameterizedType(Map::class.java, String::class.java, listType)
}
private val adapter: JsonAdapter<Map<String, List<WithdrawStoreData>>> by lazy {
appPreferencesStore.moshi.adapter(mapType)
}
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
appPreferencesStore
.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), customerWalletAddress)
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId),
)
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) =
withContext(dispatcherProvider.io) {
val json = tokensAdapter.toJson(tokens)
secureStorage.store(
json.encodeToByteArray(throwOnInvalidSequence = true),
createAuthTokensKey(customerWalletAddress),
)
}
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? {
return withContext(dispatcherProvider.io) {
val authTokens = secureStorage.get(createAuthTokensKey(customerWalletAddress))
?.decodeToString(throwOnInvalidSequence = true)
?.let(tokensAdapter::fromJson)
authTokens?.let { tokens ->
if (tokens.idempotencyKey == null) {
val newAuthTokens = tokens.copy(idempotencyKey = UUID.randomUUID().toString())
storeAuthTokens(customerWalletAddress, newAuthTokens)
newAuthTokens
} else {
tokens
}
}
}
}
override suspend fun clearAuthTokens(customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
}
override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), orderId)
}
override suspend fun getOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress))
.takeIf { !it.isNullOrEmpty() }
}
override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress),
) == true
}
override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), isDone)
}
override suspend fun getCashbackDeactivationDismissed(customerWalletAddress: String): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayCashbackDeactivationDismissedKey(customerWalletAddress),
) == true
}
override suspend fun storeCashbackDeactivationDismissed(customerWalletAddress: String, isDismissed: Boolean) {
appPreferencesStore.store(
key = PreferencesKeys.getTangemPayCashbackDeactivationDismissedKey(customerWalletAddress),
value = isDismissed,
)
}
override suspend fun clearOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) {
appPreferencesStore.store(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
value = vaOrderId,
)
}
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress),
).takeIf { !it.isNullOrEmpty() }
}
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) {
appPreferencesStore.store(PreferencesKeys.getTangemPayVirtualAccountOrderIdKey(customerWalletAddress), "")
}
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) {
appPreferencesStore.store(
PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId),
isPaeraCustomer,
)
}
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId))
}
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.plus(createWithdrawOrderIdKey(userWalletId) to orderId)
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]
?.let(adapter::fromJson)
.orEmpty()
val newItem = withdrawStoreDataConverter.convert(data)
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size + 1) {
for (item in currentList) { if (item.orderId != newItem.orderId) add(item) }
add(newItem)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] =
adapter.toJson(currentMap + (walletKey to updatedList))
}
}
override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? {
val orders = appPreferencesStore.getObjectMapSync<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
return orders[createWithdrawOrderIdKey(userWalletId)]
}
override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List<TangemPayWithdrawState> {
val map = appPreferencesStore.data.firstOrNull()
?.get(PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY)?.let(adapter::fromJson).orEmpty()
return map[createWithdrawOrderIdKey(userWalletId)].orEmpty().map(withdrawStateConverter::convert)
}
override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) {
appPreferencesStore.editData { mutablePreferences ->
val orders = mutablePreferences.getObjectMap<String>(
PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
)
.minus(createWithdrawOrderIdKey(userWalletId))
mutablePreferences.setObjectMap(
key = PreferencesKeys.TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY,
value = orders,
)
}
}
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val currentList = currentMap[walletKey].orEmpty()
val updatedList = buildList(currentList.size) {
for (item in currentList) {
if (item.orderId != orderId) add(item)
}
}
val updatedMap = if (updatedList.isEmpty()) {
currentMap - walletKey
} else {
currentMap + (walletKey to updatedList)
}
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) {
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), hide)
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return appPreferencesStore.getSyncOrNull(
key = PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId),
) == true
}
override suspend fun storeTangemPayEligibility(eligibility: Set<String>) {
withContext(dispatcherProvider.io) {
appPreferencesStore.store(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
value = eligibility,
)
}
}
override suspend fun getTangemPayEligibility(): Set<String> {
return withContext(dispatcherProvider.io) {
appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.TANGEM_PAY_ELIGIBILITY_KEY,
default = emptySet(),
)
}
}
override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) {
appPreferencesStore.store(PreferencesKeys.getTangemPayDeactivatedKey(userWalletId), true)
}
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean {
val key = PreferencesKeys.getTangemPayDeactivatedKey(userWalletId)
return appPreferencesStore.getSyncOrNull(key) == true
}
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) {
withContext(dispatcherProvider.io) {
secureStorage.delete(createAuthTokensKey(customerWalletAddress))
}
appPreferencesStore.store(PreferencesKeys.getTangemPayCheckCustomerByWalletId(userWalletId), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayCustomerWalletAddressKey(userWalletId), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "")
appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false)
appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false)
// Clear the withdraw order hints together with the rest of the cache.
deleteActiveWithdrawOrder(userWalletId)
clearWithdrawOrders(userWalletId)
}
private suspend fun clearWithdrawOrders(userWalletId: UserWalletId) {
appPreferencesStore.editData { prefs ->
val walletKey = createWithdrawOrderIdKey(userWalletId)
val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson)
.orEmpty()
val updatedMap = currentMap - walletKey
prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap)
}
}
private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address"
private fun createWithdrawOrderIdKey(userWalletId: UserWalletId): String = "${WITHDRAW_ORDER_ID_KEY}_$userWalletId"
}

View file

@ -124,6 +124,20 @@ internal class PaymentAccountStatusesStore(
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
suspend fun remove(userWalletId: UserWalletId) {
logger.i("remove($userWalletId)")
remove(userWalletIds = listOf(userWalletId))
}
suspend fun remove(userWalletIds: List<UserWalletId>) {
logger.i("remove($userWalletIds)")
val keys = userWalletIds.map { it.stringValue }.toSet()
coroutineScope {
launch { runtimeStore.update(default = emptyMap()) { it - keys } }
launch { persistenceDataStore.updateData { it - keys } }
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Payment) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {

View file

@ -0,0 +1,72 @@
package com.tangem.data.pay.store
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
@Suppress("TooManyFunctions")
interface TangemPayStorage {
suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String?
suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId)
suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens)
suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens?
suspend fun clearAuthTokens(customerWalletAddress: String)
suspend fun storeOrderId(customerWalletAddress: String, orderId: String)
suspend fun getOrderId(customerWalletAddress: String): String?
suspend fun clearOrderId(customerWalletAddress: String)
suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String)
suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String?
suspend fun clearVirtualAccountOrderId(customerWalletAddress: String)
suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean
suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean)
suspend fun getCashbackDeactivationDismissed(customerWalletAddress: String): Boolean
suspend fun storeCashbackDeactivationDismissed(customerWalletAddress: String, isDismissed: Boolean)
suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean)
suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean?
/** Called after creating withdraw order, active order id */
suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String)
/** Called after creating withdraw order, saves order data */
suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState)
/** Returns single active order id. Once the order is completed, deletes id from storage.
* Only one active order allowed for a wallet */
suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String?
/** Returns all withdraw orders saved.
* Once we get tx hash for an order, it gets deleted from this storage */
suspend fun getWithdrawOrders(userWalletId: UserWalletId): List<TangemPayWithdrawState>?
/** Deletes active withdraw order. Called after order is completed */
suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId)
/** Deletes withdraw order data. Called after getting its tx hash */
suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String)
suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean
suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean)
suspend fun storeTangemPayEligibility(eligibility: Set<String>)
suspend fun getTangemPayEligibility(): Set<String>
suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String)
suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId)
suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.CashbackAccrualDocsResponse
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.utils.converter.Converter
/** Maps [CashbackAccrualDocsResponse] (BFF) to domain [CashbackDocument]s, dropping malformed entries. */
internal object CashbackAccrualDocsConverter : Converter<CashbackAccrualDocsResponse, List<CashbackDocument>> {
override fun convert(value: CashbackAccrualDocsResponse): List<CashbackDocument> {
return value.docs.orEmpty().mapNotNull(::convertDoc)
}
private fun convertDoc(doc: CashbackAccrualDocsResponse.Doc): CashbackDocument? {
return CashbackDocument(
id = doc.id ?: return null,
title = doc.title ?: return null,
url = doc.url ?: return null,
)
}
}

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

@ -0,0 +1,40 @@
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> {
override fun convert(value: CashbackPromotionsResponse): CashbackPromotions {
return CashbackPromotions(
cardTiers = value.cashbackOnCards?.tiers.orEmpty().map(::convertTier),
additionalCashback = value.additionalCashback.orEmpty().map(::convertAdditional),
)
}
private fun convertTier(tier: CashbackPromotionsResponse.CardTier): CashbackPromotions.CardTier {
return CashbackPromotions.CardTier(
tier = tier.tier.orEmpty(),
label = tier.label.orEmpty(),
scope = tier.scope.orEmpty(),
minTransactionAmount = tier.minTransactionAmount,
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

@ -0,0 +1,46 @@
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,
pendingAmount = value.pendingAmount ?: BigDecimal.ZERO,
currency = value.currency.orEmpty(),
payoutCurrency = value.payoutCurrency.orEmpty(),
payoutNetwork = value.payoutNetwork.orEmpty(),
period = TangemPayCashback.Period(
year = period.year,
month = period.month,
payoutStart = payoutStart,
payoutEnd = payoutEnd,
),
),
)
}
}

View file

@ -1,11 +1,14 @@
package com.tangem.data.pay.util
import arrow.core.getOrElse
import com.tangem.data.pay.converter.TangemPayTariffPlanConverter
import com.tangem.datasource.api.pay.models.response.CryptoBalance
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
@ -18,6 +21,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDat
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import org.joda.time.DateTime
internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, CustomerInfo> {
override fun convert(value: CustomerMeResponse.Result): CustomerInfo {
@ -45,9 +49,25 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
fiatBalance = fiatBalance?.toDomain(),
cryptoBalance = cryptoBalance?.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount.orZero(),
tariffPlan = value.customerTariffPlan?.toDomain(),
)
}
private fun CustomerMeResponse.CustomerTariffPlan.toDomain(): TangemPayCustomerTariffPlan? {
val plan = tariffPlan?.toDomain() ?: return null
return TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.fromString(status),
source = TangemPayCustomerTariffPlan.Source.fromString(source),
plan = plan,
nextBillingAt = nextBillingAt.toDateTimeOrNull(),
pendingPlan = pendingTariffPlan?.toDomain(),
pendingTransitionAt = pendingTransitionAt.toDateTimeOrNull(),
)
}
private fun CustomerMeResponse.TariffPlan.toDomain(): TangemPayTariffPlan? =
TangemPayTariffPlanConverter.convert(this)
private fun CustomerMeResponse.ProductInstance.toDomain(): ProductInstance {
val status = status.toDomain()
val cardFrozenState = when (status) {
@ -73,6 +93,15 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
cardStatus = TangemPayCard.Status.fromString(card.cardStatus),
lastFourDigits = card.cardNumberEnd,
isPinSet = card.isPinSet == true,
images = card.images.orEmpty().mapNotNull(::convertCardImage),
)
}
private fun convertCardImage(image: CustomerMeResponse.Image): TangemPayTariffPlan.Image? {
val url = image.url ?: return null
return TangemPayTariffPlan.Image(
type = TangemPayTariffPlan.Image.Type.fromString(image.type),
url = url,
)
}
@ -116,4 +145,6 @@ internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, Cus
CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT
CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD
}
private fun String?.toDateTimeOrNull(): DateTime? = this?.let { runCatching { DateTime.parse(it) }.getOrNull() }
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.pay.util
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStep
import com.tangem.domain.pay.model.OrderType
/** Maps a wire `OrderResponse.Result` into the domain [Order] model. */
@ -15,11 +16,12 @@ internal object OrderConverter {
customerId = value.customerId,
type = type,
status = status,
step = value.step,
step = OrderStep.fromString(value.step),
stepChangeCode = value.stepChangeCode,
productInstanceId = value.data.productInstanceId,
paymentAccountId = value.data.paymentAccountId,
cardId = null, // Card id not in v1 response shape; resolved via productInstanceId.
toTariffPlanId = value.data.targetTariffPlanId,
withdrawTxHash = value.data.transactionHash?.ifEmpty { null },
createdAt = value.createdAt,
updatedAt = value.updatedAt,

View file

@ -32,7 +32,7 @@ internal class VisaLibLoader @Inject constructor(
val config = getOrLoadConfig()
provider = VisaContractInfoProvider.Builder(
useTestnetRpc = VisaConstants.USE_TEST_ENV,
isTestnetRpcEnabled = VisaConstants.USE_TEST_ENV,
bridgeProcessorAddress = if (VisaConstants.USE_TEST_ENV) {
config.testnet.bridgeProcessor
} else {

View file

@ -55,6 +55,8 @@ internal class TangemPayTxHistoryItemConverter(moshi: Moshi) :
status = TangemPayTxHistoryItemStatusConverter.convert(spend.status),
enrichedMerchantIconUrl = spend.enrichedMerchantIcon,
declinedReason = spend.declinedReason,
cardName = spend.cardDisplayName,
cardNumberLast4 = spend.cardNumberEnd,
)
}

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,151 @@
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.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
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 canned cashback data; 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)
}
override suspend fun getCashbackPromotions(
userWalletId: UserWalletId,
): Either<VisaApiError, CashbackPromotions> {
if (isMockMode) return MOCK_PROMOTIONS.right()
return real.getCashbackPromotions(userWalletId)
}
override suspend fun getCashbackAccrualDocs(
userWalletId: UserWalletId,
): Either<VisaApiError, List<CashbackDocument>> {
if (isMockMode) return MOCK_DOCS.right()
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)
override suspend fun setDeactivationBannerDismissed(userWalletId: UserWalletId) =
real.setDeactivationBannerDismissed(userWalletId)
private companion object {
val MOCK_SUMMARY = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = TangemPayCashback(
confirmedAmount = BigDecimal("22.54"),
pendingAmount = BigDecimal("13.65"),
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"),
),
),
)
val MOCK_PROMOTIONS = CashbackPromotions(
cardTiers = listOf(
CashbackPromotions.CardTier(
tier = "basic",
label = "Basic cards",
scope = "All purchases",
minTransactionAmount = BigDecimal("30"),
monthlyCapAmount = BigDecimal("100"),
),
CashbackPromotions.CardTier(
tier = "plus",
label = "Plus cards",
scope = "All purchases",
minTransactionAmount = BigDecimal("30"),
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(
CashbackDocument(
id = "excluded",
title = "All categories without cashback",
url = "https://tangem.com/docs/en/tangem-pay-cashback-excluded-mccs.pdf",
),
CashbackDocument(
id = "terms",
title = "Full terms of cashback program",
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

@ -16,7 +16,16 @@ 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 only skips the local-storage / NFC-signing enrollment steps (order ids, initial data). The
* customer-facing state whether a wallet has Tangem Pay ([hasTangemPayInWallet]), KYC status, ACTIVE /
* INACTIVE, balances ([getCustomerInfo]) is driven by the WireMock test scenario (authenticated with the
* synthetic tokens from [com.tangem.data.pay.store.MockAwareTangemPayStorage]) rather than hardcoded:
* - [hasTangemPayInWallet] delegates to the real repo, so the "existing customer" gate follows the
* checkCustomerWalletId mock (the `tangem_pay_eligibility` scenario: `Started` 404/NotPaeraCustomer
* no Payment account, `PaeraCustomer` 200 Payment account);
* - [getCustomerInfo] delegates to the real repo (WireMock), so KYC / customer-state scenarios take effect.
*/
@Singleton
internal class MockAwareOnboardingRepository @Inject constructor(
private val real: DefaultOnboardingRepository,
@ -46,6 +55,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.produceInitialData(userWalletId)
}
// Delegates to WireMock (via the real repo + synthetic storage tokens) so the customer state — KYC status,
// ACTIVE/INACTIVE, balances — follows the test scenario instead of a hardcoded "always active" customer.
// Note: this may be invoked even when `hasTangemPayInWallet` is false (e.g., onboarding/deeplink flows),
// so tests must provide the corresponding WireMock mappings.
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> =
real.getCustomerInfo(userWalletId)
@ -108,6 +121,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
real.clearVirtualAccountOrderId(userWalletId)
}
// The "existing Tangem Pay customer" gate (decides whether an active Payment account — and accounts mode —
// appears). Delegates to WireMock's checkCustomerWalletId via the real repo (static token, no signing), so it
// is driven by the `tangem_pay_eligibility` scenario: `Started` (default) → 404/NotPaeraCustomer → no account;
// `PaeraCustomer` → 200 → account. Generic UI tests never set the scenario, so they stay Payment-free.
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> =
real.hasTangemPayInWallet(userWalletId)

View file

@ -0,0 +1,141 @@
package com.tangem.data.pay.store
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.TangemPayWithdrawState
import com.tangem.domain.visa.model.TangemPayAuthTokens
import javax.inject.Inject
import javax.inject.Singleton
private const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002"
private const val MOCK_ACCESS_TOKEN = "mock-access-token"
private const val MOCK_REFRESH_TOKEN = "mock-refresh-token"
private const val MOCK_IDEMPOTENCY_KEY = "mock-idempotency-key"
private const val MOCK_TOKEN_EXPIRES_AT = 9_999_999_999L
/** In MOCK env returns synthetic auth tokens + customer wallet address; otherwise delegates. */
@Singleton
internal class MockAwareTangemPayStorage @Inject constructor(
private val real: DefaultTangemPayStorage,
private val apiConfigsManager: ApiConfigsManager,
) : TangemPayStorage {
private val isMockMode: Boolean
get() = apiConfigsManager
.getEnvironmentConfig(ApiConfig.ID.TangemPay)
.environment == ApiEnvironment.MOCK
override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) {
if (isMockMode) return
real.storeCustomerWalletAddress(userWalletId, customerWalletAddress)
}
override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? {
if (isMockMode) return MOCK_CUSTOMER_WALLET_ADDRESS
return real.getCustomerWalletAddress(userWalletId)
}
override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) {
if (isMockMode) return
real.clearCustomerWalletAddress(userWalletId)
}
override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) {
if (isMockMode) return
real.storeAuthTokens(customerWalletAddress, tokens)
}
override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? {
if (isMockMode) {
return TangemPayAuthTokens(
accessToken = MOCK_ACCESS_TOKEN,
expiresAt = MOCK_TOKEN_EXPIRES_AT,
refreshToken = MOCK_REFRESH_TOKEN,
refreshExpiresAt = MOCK_TOKEN_EXPIRES_AT,
idempotencyKey = MOCK_IDEMPOTENCY_KEY,
)
}
return real.getAuthTokens(customerWalletAddress)
}
override suspend fun clearAuthTokens(customerWalletAddress: String) {
if (isMockMode) return
real.clearAuthTokens(customerWalletAddress)
}
override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) =
real.storeOrderId(customerWalletAddress, orderId)
override suspend fun getOrderId(customerWalletAddress: String): String? =
real.getOrderId(customerWalletAddress)
override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean =
real.getAddToWalletDone(customerWalletAddress)
override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) =
real.storeAddToWalletDone(customerWalletAddress, isDone)
override suspend fun getCashbackDeactivationDismissed(customerWalletAddress: String): Boolean =
real.getCashbackDeactivationDismissed(customerWalletAddress)
override suspend fun storeCashbackDeactivationDismissed(customerWalletAddress: String, isDismissed: Boolean) =
real.storeCashbackDeactivationDismissed(customerWalletAddress, isDismissed)
override suspend fun clearOrderId(customerWalletAddress: String) =
real.clearOrderId(customerWalletAddress)
override suspend fun storeVirtualAccountOrderId(customerWalletAddress: String, vaOrderId: String) =
real.storeVirtualAccountOrderId(customerWalletAddress, vaOrderId)
override suspend fun getVirtualAccountOrderId(customerWalletAddress: String): String? =
real.getVirtualAccountOrderId(customerWalletAddress)
override suspend fun clearVirtualAccountOrderId(customerWalletAddress: String) =
real.clearVirtualAccountOrderId(customerWalletAddress)
override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) =
real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer)
override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? =
real.checkCustomerWalletResult(userWalletId)
override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) =
real.storeActiveWithdrawOrderId(userWalletId, orderId)
override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) =
real.storeWithdrawOrder(userWalletId, data)
override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? =
real.getActiveWithdrawOrderId(userWalletId)
override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List<TangemPayWithdrawState>? =
real.getWithdrawOrders(userWalletId)
override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) =
real.deleteActiveWithdrawOrder(userWalletId)
override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) =
real.deleteWithdrawOrder(userWalletId, orderId)
override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) =
real.storeHideOnboardingBanner(userWalletId, hide)
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean =
real.getHideMainOnboardingBanner(userWalletId)
override suspend fun storeTangemPayEligibility(eligibility: Set<String>) =
real.storeTangemPayEligibility(eligibility)
override suspend fun getTangemPayEligibility(): Set<String> = real.getTangemPayEligibility()
override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) =
real.storeIsTangemPayDeactivated(userWalletId)
override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean =
real.isTangemPayDeactivated(userWalletId)
override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) =
real.clearAll(userWalletId, customerWalletAddress)
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.pay.store.di
import com.tangem.data.pay.store.MockAwareTangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface TangemPayStorageMockedModule {
@Binds
@Singleton
fun bindTangemPayStorage(impl: MockAwareTangemPayStorage): TangemPayStorage
}

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,18 @@
package com.tangem.data.pay.store.di
import com.tangem.data.pay.store.DefaultTangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface TangemPayStorageProductionModule {
@Binds
@Singleton
fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage
}

View file

@ -0,0 +1,49 @@
package com.tangem.data.pay
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TangemPayUserWalletDataCleanerTest {
private val paymentAccountStatusesStore: PaymentAccountStatusesStore = mockk()
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore = mockk()
private val cleaner = TangemPayUserWalletDataCleaner(
paymentAccountStatusesStore = paymentAccountStatusesStore,
txHistoryItemsStore = txHistoryItemsStore,
)
@BeforeEach
fun resetMocks() {
clearMocks(paymentAccountStatusesStore, txHistoryItemsStore)
coEvery { paymentAccountStatusesStore.remove(any<List<UserWalletId>>()) } just Runs
coEvery { txHistoryItemsStore.remove(any<List<String>>()) } just Runs
}
@Test
fun `GIVEN wallets WHEN clear THEN each store is cleared once with all ids in a single call`() = runTest {
// Act
cleaner.clear(listOf(WALLET_A, WALLET_B))
// Assert
coVerify(exactly = 1) { paymentAccountStatusesStore.remove(listOf(WALLET_A, WALLET_B)) }
coVerify(exactly = 1) { txHistoryItemsStore.remove(listOf(WALLET_A.stringValue, WALLET_B.stringValue)) }
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

@ -103,6 +103,40 @@ internal class PaymentAccountStatusValueDMConverterTest {
assertThat(result).isNull()
}
@Test
fun `GIVEN domain AwaitingPlanSelection WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.AwaitingPlanSelection(
source = StatusSource.ACTUAL,
tariffPlan = mockk(),
)
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain Inactive WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Inactive(
source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
),
tariffPlan = mockk(),
)
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain Error Unavailable WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN

View file

@ -0,0 +1,218 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class TangemPayTariffPlanConverterTest {
@Test
fun `GIVEN null value WHEN convert THEN returns null`() {
assertThat(TangemPayTariffPlanConverter.convert(null)).isNull()
}
@Test
fun `GIVEN missing id WHEN convert THEN returns null`() {
val value = tariffPlan(id = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN missing name WHEN convert THEN returns null`() {
val value = tariffPlan(name = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN missing type WHEN convert THEN returns null`() {
val value = tariffPlan(type = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN missing programName WHEN convert THEN returns null`() {
val value = tariffPlan(programName = null)
assertThat(TangemPayTariffPlanConverter.convert(value)).isNull()
}
@Test
fun `GIVEN basic tier in mixed case WHEN convert THEN isBasicTier is true`() {
val value = tariffPlan(type = "Basic")
val result = TangemPayTariffPlanConverter.convert(value)
assertThat(result?.tierId).isEqualTo("Basic")
assertThat(result?.isBasicTier).isTrue()
}
@Test
fun `GIVEN full valid plan WHEN convert THEN maps all fields`() {
// GIVEN
val value = tariffPlan(
type = "PLUS",
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(
type = "PLAN_RELATED",
order = 2,
title = "Title",
body = "Body",
),
),
images = listOf(CustomerMeResponse.Image(type = "MAIN", url = "https://img")),
fees = listOf(
CustomerMeResponse.Fee(
type = "RECURRING",
amount = BigDecimal("9.99"),
currency = "USD",
description = "Monthly",
period = "MONTH",
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
val expected = TangemPayTariffPlan(
id = PLAN_ID,
tierId = "PLUS",
isBasicTier = false,
name = PLAN_NAME,
programName = PROGRAM_NAME,
descriptionItems = listOf(
TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.PLAN_RELATED,
order = 2,
title = "Title",
body = "Body",
),
),
images = listOf(
TangemPayTariffPlan.Image(type = TangemPayTariffPlan.Image.Type.MAIN, url = "https://img"),
),
fees = listOf(
TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.RECURRING,
amount = BigDecimal("9.99"),
currency = "USD",
description = "Monthly",
period = TangemPayTariffPlan.Fee.Period.MONTH,
),
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN unknown enum strings and null optional fields WHEN convert THEN falls back to defaults`() {
// GIVEN
val value = tariffPlan(
type = "SOMETHING_NEW",
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(type = "wat", order = null, title = "Title", body = null),
),
images = listOf(CustomerMeResponse.Image(type = null, url = "https://img")),
fees = listOf(
CustomerMeResponse.Fee(
type = null,
amount = BigDecimal.ONE,
currency = null,
description = null,
period = null,
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
val expected = TangemPayTariffPlan(
id = PLAN_ID,
tierId = "SOMETHING_NEW",
isBasicTier = false,
name = PLAN_NAME,
programName = PROGRAM_NAME,
descriptionItems = listOf(
TangemPayTariffPlan.DescriptionItem(
section = TangemPayTariffPlan.Section.UNKNOWN,
order = 0,
title = "Title",
body = "",
),
),
images = listOf(
TangemPayTariffPlan.Image(type = TangemPayTariffPlan.Image.Type.UNKNOWN, url = "https://img"),
),
fees = listOf(
TangemPayTariffPlan.Fee(
type = TangemPayTariffPlan.Fee.Type.UNKNOWN,
amount = BigDecimal.ONE,
currency = "",
description = "",
period = null,
),
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `GIVEN nested items with missing required fields WHEN convert THEN filters them out`() {
// GIVEN
val value = tariffPlan(
descriptionItems = listOf(
CustomerMeResponse.DescriptionItem(type = "PLAN_RELATED", order = 1, title = null, body = "Body"),
),
images = listOf(CustomerMeResponse.Image(type = "MAIN", url = null)),
fees = listOf(
CustomerMeResponse.Fee(
type = "FREE",
amount = null,
currency = "USD",
description = "d",
period = null,
),
),
)
// WHEN
val result = TangemPayTariffPlanConverter.convert(value)
// THEN
assertThat(result?.descriptionItems).isEmpty()
assertThat(result?.images).isEmpty()
assertThat(result?.fees).isEmpty()
}
private fun tariffPlan(
id: String? = PLAN_ID,
type: String? = "BASIC",
name: String? = PLAN_NAME,
programName: String? = PROGRAM_NAME,
descriptionItems: List<CustomerMeResponse.DescriptionItem>? = null,
images: List<CustomerMeResponse.Image>? = null,
fees: List<CustomerMeResponse.Fee>? = null,
) = CustomerMeResponse.TariffPlan(
id = id,
type = type,
name = name,
programName = programName,
descriptionItems = descriptionItems,
images = images,
fees = fees,
)
private companion object {
const val PLAN_ID = "plan-1"
const val PLAN_NAME = "Plus"
const val PROGRAM_NAME = "program-1"
}
}

View file

@ -14,11 +14,15 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.BankCredentials
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanState
import com.tangem.domain.models.account.VirtualAccountOnramp
import com.tangem.domain.models.currency.CryptoCurrency
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.TangemPayCardState
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -28,8 +32,10 @@ import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.GetTangemPayTariffPlanStateUseCase
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.test.core.TestAppCoroutineScope
@ -59,6 +65,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk()
private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles = mockk()
private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk()
private val getTangemPayTariffPlanStateUseCase: GetTangemPayTariffPlanStateUseCase = mockk()
private val fetcher = DefaultPaymentAccountStatusFetcher(
paymentAccountStatusesStore = paymentAccountStatusesStore,
@ -74,6 +82,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
tangemPayFeatureToggles = tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
)
private val userWalletId = UserWalletId("011")
@ -116,28 +126,54 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardStatus = TangemPayCard.Status.ACTIVE,
lastFourDigits = "1234",
isPinSet = true,
images = emptyList(),
)
private fun buildCustomerInfo(productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance)) =
CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.TEN,
currency = "USD",
),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = "usdc",
chainId = 137L,
depositAddress = "0xdeposit",
tokenContractAddress = "0xcontract",
balance = BigDecimal.TEN,
),
availableForWithdrawal = BigDecimal.TEN,
cards = listOf(cardInfo),
productInstances = productInstances,
)
private val basicPlan = TangemPayTariffPlan(
id = "plan_basic",
tierId = "BASIC",
isBasicTier = true,
name = "Basic",
programName = "program_basic",
descriptionItems = emptyList(),
images = emptyList(),
fees = emptyList(),
)
private val customerTariffPlan = TangemPayCustomerTariffPlan(
status = TangemPayCustomerTariffPlan.Status.ACTIVE,
source = TangemPayCustomerTariffPlan.Source.CUSTOMER,
plan = basicPlan,
nextBillingAt = null,
pendingPlan = null,
pendingTransitionAt = null,
)
private fun buildCustomerInfo(
productInstances: List<CustomerInfo.ProductInstance> = listOf(cardProductInstance),
fiatBalance: PaymentAccountStatusValue.FiatBalance? = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal.TEN,
currency = "USD",
),
cryptoBalance: PaymentAccountStatusValue.CryptoBalance? = PaymentAccountStatusValue.CryptoBalance(
id = "usdc",
chainId = 137L,
depositAddress = "0xdeposit",
tokenContractAddress = "0xcontract",
balance = BigDecimal.TEN,
),
tariffPlan: TangemPayCustomerTariffPlan? = null,
) = CustomerInfo(
customerId = "cust_1",
kycStatus = KycStatus.APPROVED,
state = CustomerInfo.State.ACTIVE,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = BigDecimal.TEN,
cards = listOf(cardInfo),
productInstances = productInstances,
tariffPlan = tariffPlan,
)
@BeforeEach
fun setUp() {
@ -151,10 +187,14 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository,
issueCardRepository,
virtualAccountFeatureToggles,
tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase,
)
// Relaxed mocks don't need clearing — deviceSecurity, eligibilityManager, paymentAccountStatusesStore
// are relaxed and consistent with their relaxed defaults (false, empty, etc.)
clearMocks(paymentAccountStatusesStore, answers = false)
// Tiers off by default — legacy auto-order-creation behavior. Individual tests override.
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
}
/**
@ -228,6 +268,7 @@ internal class DefaultPaymentAccountStatusFetcherTest {
fiatRate = null,
error = null,
virtualAccount = virtualAccount,
tariffPlan = null
)
}
@ -466,6 +507,8 @@ internal class DefaultPaymentAccountStatusFetcherTest {
cardDetailsRepository = cardDetailsRepository,
issueCardRepository = issueCardRepository,
virtualAccountFeatureToggles = virtualAccountFeatureToggles,
tangemPayFeatureToggles = tangemPayFeatureToggles,
getTangemPayTariffPlanStateUseCase = getTangemPayTariffPlanStateUseCase,
)
private val account = Account.Payment(userWalletId = userWalletId)
@ -506,4 +549,142 @@ internal class DefaultPaymentAccountStatusFetcherTest {
assertThat(realStore.getSyncOrNull(userWalletId)?.value).isEqualTo(issuingCard)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class TiersPlanSelection {
@Test
fun `GIVEN tiers on and KYC approved and no plan order WHEN invoke THEN stores AwaitingPlanSelection`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = customerTariffPlan,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns emptyList()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.AwaitingPlanSelection::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers on and fallback plan is missing WHEN invoke THEN stores IssuingCard without order`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = null,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers on and plan selected but no balance yet WHEN invoke THEN stores Inactive`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(
productInstances = emptyList(),
fiatBalance = null,
cryptoBalance = null,
tariffPlan = customerTariffPlan,
)
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery {
getTangemPayTariffPlanStateUseCase(userWalletId = userWalletId, tariff = customerTariffPlan)
} returns TangemPayTariffPlanState(tariff = customerTariffPlan, order = null)
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.Inactive::class.java)
coVerify(exactly = 0) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers off and KYC approved without card WHEN invoke THEN creates order and stays issuing`() = runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1")
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
coVerify(exactly = 1) { onboardingRepository.createOrder(userWalletId) }
}
@Test
fun `GIVEN tiers off and balance without instances but local issue order WHEN invoke THEN stores IssuingCard`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns false
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery { onboardingRepository.createOrder(userWalletId) } returns Either.Right("order_1")
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
assertThat(storedStatuses.last().value)
.isInstanceOf(PaymentAccountStatusValue.IssuingCard::class.java)
}
@Test
fun `GIVEN tiers on and balance without instances but local issue order WHEN invoke THEN stores Loaded with placeholder`() =
runTest {
// Arrange
val customerInfo = buildCustomerInfo(productInstances = emptyList())
stubHappyPath(customerInfo)
every { tangemPayFeatureToggles.isTiersPlusPlanEnabled } returns true
every { virtualAccountFeatureToggles.isVaMvp0Enabled } returns false
coEvery { issueCardRepository.getIssueOrderIds(userWalletId) } returns listOf("order_1")
coEvery {
cardDetailsRepository.getOrderInfo(userWalletId, "order_1")
} returns VisaApiError.UnknownWithoutCode.left()
val storedStatuses = captureStoredStatuses()
// Act
fetcher.invoke(params)
// Assert
val loaded = storedStatuses.lastLoaded()
assertThat(loaded.cards).hasSize(1)
assertThat(loaded.cards.single().state).isEqualTo(TangemPayCardState.Issuing)
}
}
}

View file

@ -74,6 +74,7 @@ internal class StableOrderTest {
limit = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "0000",
images = emptyList(),
state = TangemPayCardState.Active,
)
}

View file

@ -0,0 +1,106 @@
package com.tangem.data.pay.repository
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.util.CashbackAccrualDocsConverter
import com.tangem.data.pay.util.CashbackPromotionsConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.CashbackAccrualDocsResponse
import com.tangem.datasource.api.pay.models.response.CashbackPromotionsResponse
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultCashbackRepositoryTest {
private val tangemPayApi: TangemPayApi = mockk()
private val requestHelper: TangemPayRequestPerformer = mockk()
private val userWalletId = UserWalletId("011")
@BeforeEach
fun setUp() {
clearMocks(tangemPayApi, requestHelper)
coEvery {
requestHelper.performRequest<Any>(userWalletId = any(), requestBlock = any())
} coAnswers {
val block = secondArg<suspend (String) -> ApiResponse<Any>>()
when (val response = block(AUTH_HEADER)) {
is ApiResponse.Success -> response.data.right()
is ApiResponse.Error -> VisaApiError.Unspecified.left()
}
}
}
@Test
fun `GIVEN promotions response WHEN getCashbackPromotions THEN converter result is returned`() = runTest {
// Arrange
val response = CashbackPromotionsResponse(cashbackOnCards = null, additionalCashback = null)
coEvery { tangemPayApi.getCashbackPromotions(any()) } returns ApiResponse.Success(response)
// Act
val actual = createRepository().getCashbackPromotions(userWalletId)
// Assert
assertThat(actual).isEqualTo(CashbackPromotionsConverter.convert(response).right())
}
@Test
fun `GIVEN backend error WHEN getCashbackPromotions THEN error is propagated`() = runTest {
// Arrange
coEvery { tangemPayApi.getCashbackPromotions(any()) } returns
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<CashbackPromotionsResponse>
// Act
val actual = createRepository().getCashbackPromotions(userWalletId)
// Assert
assertThat(actual).isEqualTo(VisaApiError.Unspecified.left())
}
@Test
fun `GIVEN docs response WHEN getCashbackAccrualDocs THEN converter result is returned`() = runTest {
// Arrange
val response = CashbackAccrualDocsResponse(
docs = listOf(CashbackAccrualDocsResponse.Doc(id = "1", title = "Terms", url = "https://a")),
)
coEvery { tangemPayApi.getCashbackAccrualDocs(any()) } returns ApiResponse.Success(response)
// Act
val actual = createRepository().getCashbackAccrualDocs(userWalletId)
// Assert
assertThat(actual).isEqualTo(CashbackAccrualDocsConverter.convert(response).right())
}
@Test
fun `GIVEN backend error WHEN getCashbackAccrualDocs THEN error is propagated`() = runTest {
// Arrange
coEvery { tangemPayApi.getCashbackAccrualDocs(any()) } returns
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<CashbackAccrualDocsResponse>
// Act
val actual = createRepository().getCashbackAccrualDocs(userWalletId)
// Assert
assertThat(actual).isEqualTo(VisaApiError.Unspecified.left())
}
private fun createRepository() = DefaultCashbackRepository(
tangemPayApi = tangemPayApi,
requestHelper = requestHelper,
storage = mockk(),
)
private companion object {
const val AUTH_HEADER = "auth-header"
}
}

View file

@ -57,7 +57,7 @@ internal class DefaultCustomerOffersRepositoryTest {
}
@Test
fun `GIVEN offers fetched once WHEN getOffers called twice THEN backend is hit only once`() = runTest {
fun `GIVEN offers response WHEN getOffers called twice THEN backend is hit each time`() = runTest {
// Arrange
val repository = createRepository()
@ -68,11 +68,11 @@ internal class DefaultCustomerOffersRepositoryTest {
// Assert
assertThat(first.isRight()).isTrue()
assertThat(second).isEqualTo(first)
coVerify(exactly = 1) { tangemPayApi.getCustomerOffers(any()) }
coVerify(exactly = 2) { tangemPayApi.getCustomerOffers(any()) }
}
@Test
fun `GIVEN backend error WHEN getOffers called again THEN error is not cached and backend is hit again`() = runTest {
fun `GIVEN backend error WHEN getOffers called again THEN backend is hit again`() = runTest {
// Arrange
coEvery { tangemPayApi.getCustomerOffers(any()) } returnsMany listOf(
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<CustomerOffersResponse>,

View file

@ -10,7 +10,7 @@ import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.WithdrawDataResponse
import com.tangem.datasource.api.pay.models.response.WithdrawResponse
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.data.pay.store.TangemPayStorage
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId

View file

@ -0,0 +1,206 @@
package com.tangem.data.pay.repository
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.SetPendingTariffPlanTransitionRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionResponse
import com.tangem.datasource.api.pay.models.response.TariffPlanTransitionsResponse
import com.tangem.domain.models.account.TangemPayTariffPlan
import com.tangem.domain.models.account.TangemPayTariffPlanTransition
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultTariffPlanTransitionsRepositoryTest {
private val tangemPayApi: TangemPayApi = mockk()
private val requestHelper: TangemPayRequestPerformer = mockk()
private val repository = DefaultTariffPlanTransitionsRepository(
tangemPayApi = tangemPayApi,
requestHelper = requestHelper,
)
@BeforeEach
fun setUp() {
coEvery {
requestHelper.performRequest<Any>(userWalletId = any(), requestBlock = any())
} coAnswers {
val block = secondArg<suspend (String) -> ApiResponse<Any>>()
when (val response = block(AUTH_HEADER)) {
is ApiResponse.Success -> response.data.right()
is ApiResponse.Error -> VisaApiError.Unspecified.left()
}
}
}
@Test
fun `GIVEN backend error WHEN getTransitions THEN returns error`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns
ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<TariffPlanTransitionsResponse>
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
@Test
fun `GIVEN null result WHEN getTransitions THEN returns empty list`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns
ApiResponse.Success(TariffPlanTransitionsResponse(result = null))
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
assertThat(result.getOrNull()).isEqualTo(emptyList<TangemPayTariffPlanTransition>())
}
@Test
fun `GIVEN valid transitions WHEN getTransitions THEN maps them to domain`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns ApiResponse.Success(
TariffPlanTransitionsResponse(
result = listOf(
TariffPlanTransitionResponse(type = "UPGRADE", tariffPlan = tariffPlan()),
),
),
)
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
val expected = listOf(
TangemPayTariffPlanTransition(
type = TangemPayTariffPlanTransition.Type.UPGRADE,
plan = TangemPayTariffPlan(
id = PLAN_ID,
tierId = "PLUS",
isBasicTier = false,
name = PLAN_NAME,
programName = PROGRAM_NAME,
descriptionItems = emptyList(),
images = emptyList(),
fees = emptyList(),
),
),
)
assertThat(result.getOrNull()).isEqualTo(expected)
}
@Test
fun `GIVEN a transition with unconvertible plan WHEN getTransitions THEN it is filtered out`() = runTest {
// GIVEN
coEvery { tangemPayApi.getTariffPlanTransitions(any()) } returns ApiResponse.Success(
TariffPlanTransitionsResponse(
result = listOf(
TariffPlanTransitionResponse(type = "UPGRADE", tariffPlan = null),
TariffPlanTransitionResponse(type = "DOWNGRADE", tariffPlan = tariffPlan(id = null)),
TariffPlanTransitionResponse(type = "ACTIVATION", tariffPlan = tariffPlan()),
),
),
)
// WHEN
val result = repository.getTransitions(USER_WALLET_ID)
// THEN
val transitions = result.getOrNull().orEmpty()
assertThat(transitions.map { it.type })
.containsExactly(TangemPayTariffPlanTransition.Type.ACTIVATION)
}
@Test
fun `GIVEN success WHEN setPendingTransition THEN sends pending plan id and returns Unit`() = runTest {
// GIVEN
val bodySlot = slot<SetPendingTariffPlanTransitionRequest>()
coEvery {
tangemPayApi.setPendingTariffPlanTransition(any(), capture(bodySlot))
} returns ApiResponse.Success(Unit) as ApiResponse<Any>
// WHEN
val result = repository.setPendingTransition(USER_WALLET_ID, PENDING_PLAN_ID)
// THEN
assertThat(result.isRight()).isTrue()
assertThat(bodySlot.captured.pendingTariffPlanId).isEqualTo(PENDING_PLAN_ID)
}
@Test
fun `GIVEN backend error WHEN setPendingTransition THEN returns error`() = runTest {
// GIVEN
coEvery {
tangemPayApi.setPendingTariffPlanTransition(any(), any())
} returns ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<Any>
// WHEN
val result = repository.setPendingTransition(USER_WALLET_ID, PENDING_PLAN_ID)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
@Test
fun `GIVEN success WHEN cancelPendingTransition THEN returns Unit`() = runTest {
// GIVEN
coEvery {
tangemPayApi.cancelPendingTariffPlanTransition(any())
} returns ApiResponse.Success(Unit) as ApiResponse<Any>
// WHEN
val result = repository.cancelPendingTransition(USER_WALLET_ID)
// THEN
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { tangemPayApi.cancelPendingTariffPlanTransition(AUTH_HEADER) }
}
@Test
fun `GIVEN backend error WHEN cancelPendingTransition THEN returns error`() = runTest {
// GIVEN
coEvery {
tangemPayApi.cancelPendingTariffPlanTransition(any())
} returns ApiResponse.Error(ApiResponseError.NetworkException()) as ApiResponse<Any>
// WHEN
val result = repository.cancelPendingTransition(USER_WALLET_ID)
// THEN
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
private fun tariffPlan(id: String? = PLAN_ID) = CustomerMeResponse.TariffPlan(
id = id,
type = "PLUS",
name = PLAN_NAME,
programName = PROGRAM_NAME,
descriptionItems = null,
images = null,
fees = null,
)
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val AUTH_HEADER = "auth-header"
const val PLAN_ID = "plan-plus"
const val PLAN_NAME = "Plus"
const val PROGRAM_NAME = "program-plus"
const val PENDING_PLAN_ID = "plan-basic"
}
}

View file

@ -0,0 +1,89 @@
package com.tangem.data.pay.store
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class PaymentAccountStatusesStoreTest {
@Test
fun `GIVEN stored status WHEN remove single THEN cleared from both runtime and persistence`() = runTest {
// Arrange
val persistenceDataStore = MockStateDataStore<WalletIdWithPaymentStatusDM>(default = emptyMap())
val store = createStore(persistenceDataStore)
advanceUntilIdle()
store.storeNotCreated(WALLET_A)
advanceUntilIdle()
// sanity: present in both stores before removal
assertThat(store.getSyncOrNull(WALLET_A)).isNotNull()
assertThat(persistenceDataStore.data.first()).containsKey(WALLET_A.stringValue)
// Act
store.remove(WALLET_A)
advanceUntilIdle()
// Assert
assertThat(store.getSyncOrNull(WALLET_A)).isNull()
assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_A.stringValue)
}
@Test
fun `GIVEN statuses for two wallets WHEN remove list THEN both cleared from both stores`() = runTest {
// Arrange
val persistenceDataStore = MockStateDataStore<WalletIdWithPaymentStatusDM>(default = emptyMap())
val store = createStore(persistenceDataStore)
advanceUntilIdle()
store.storeNotCreated(WALLET_A)
store.storeNotCreated(WALLET_B)
advanceUntilIdle()
// Act
store.remove(listOf(WALLET_A, WALLET_B))
advanceUntilIdle()
// Assert
assertThat(store.getSyncOrNull(WALLET_A)).isNull()
assertThat(store.getSyncOrNull(WALLET_B)).isNull()
assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_A.stringValue)
assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_B.stringValue)
}
private fun TestScope.createStore(
persistenceDataStore: MockStateDataStore<WalletIdWithPaymentStatusDM>,
) = PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore<WalletIdWithPaymentStatus>(),
persistenceDataStore = persistenceDataStore,
converter = PaymentAccountStatusValueDMConverter(mockk<TangemPayCurrencyFactory>(relaxed = true)),
scope = TestAppCoroutineScope(this),
)
private suspend fun PaymentAccountStatusesStore.storeNotCreated(userWalletId: UserWalletId) {
store(
userWalletId = userWalletId,
status = AccountStatus.Payment(
account = Account.Payment(userWalletId),
value = PaymentAccountStatusValue.NotCreated,
),
)
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

@ -0,0 +1,65 @@
package com.tangem.data.pay.util
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.pay.models.response.CashbackAccrualDocsResponse
import com.tangem.datasource.api.pay.models.response.CashbackAccrualDocsResponse.Doc
import com.tangem.domain.pay.model.CashbackDocument
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CashbackAccrualDocsConverterTest {
@Test
fun `GIVEN valid docs WHEN convert THEN all docs mapped in order`() {
// Arrange
val response = response(
doc(id = "1", title = "All categories", url = "https://a"),
doc(id = "2", title = "Full terms", url = "https://b"),
)
// Act
val result = CashbackAccrualDocsConverter.convert(response)
// Assert
assertThat(result).containsExactly(
CashbackDocument(id = "1", title = "All categories", url = "https://a"),
CashbackDocument(id = "2", title = "Full terms", url = "https://b"),
).inOrder()
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN doc missing a required field WHEN convert THEN it is dropped`(malformed: Doc) {
// Arrange
val response = response(doc(), malformed)
// Act
val result = CashbackAccrualDocsConverter.convert(response)
// Assert
assertThat(result).containsExactly(CashbackDocument(id = "1", title = "Title", url = "https://a"))
}
@Test
fun `GIVEN null docs WHEN convert THEN empty list`() {
// Act
val result = CashbackAccrualDocsConverter.convert(CashbackAccrualDocsResponse(docs = null))
// Assert
assertThat(result).isEmpty()
}
private fun provideTestModels() = listOf(
doc(id = null),
doc(title = null),
doc(url = null),
)
private fun response(vararg docs: Doc) = CashbackAccrualDocsResponse(docs = docs.toList())
private fun doc(id: String? = "1", title: String? = "Title", url: String? = "https://a") =
Doc(id = id, title = title, url = url)
}

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,186 @@
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
internal class CashbackPromotionsConverterTest {
@Test
fun `GIVEN tiers WHEN convert THEN each tier mapped with its fields`() {
// Arrange
val response = response(tier(min = BigDecimal("30"), cap = BigDecimal("100")))
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result).isEqualTo(
CashbackPromotions(
cardTiers = listOf(
CashbackPromotions.CardTier(
tier = "basic",
label = "Basic",
scope = "All purchases",
minTransactionAmount = BigDecimal("30"),
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,
),
)
}
@Test
fun `GIVEN null cashbackOnCards WHEN convert THEN no card tiers`() {
// Arrange
val response = CashbackPromotionsResponse(cashbackOnCards = null, additionalCashback = null)
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result.cardTiers).isEmpty()
}
@Test
fun `GIVEN tier with null strings WHEN convert THEN strings default to empty and amounts stay null`() {
// Arrange
val response = response(tier(tier = null, label = null, scope = null))
// Act
val result = CashbackPromotionsConverter.convert(response)
// Assert
assertThat(result.cardTiers).containsExactly(
CashbackPromotions.CardTier(
tier = "",
label = "",
scope = "",
minTransactionAmount = null,
monthlyCapAmount = null,
),
)
}
private fun response(vararg tiers: CashbackPromotionsResponse.CardTier) = CashbackPromotionsResponse(
cashbackOnCards = CashbackPromotionsResponse.CashbackOnCards(
tiers = tiers.toList(),
monthlyCapAmount = null,
monthlyCapCurrency = null,
),
additionalCashback = null,
)
private fun tier(
tier: String? = "basic",
label: String? = "Basic",
scope: String? = "All purchases",
min: BigDecimal? = null,
cap: BigDecimal? = null,
) = CashbackPromotionsResponse.CardTier(
tier = tier,
label = label,
scope = scope,
minTransactionAmount = min,
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

@ -0,0 +1,145 @@
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(),
),
),
ConvertModel(
name = "enabled + alt_block -> Enabled(ALT_BLOCK)",
response = createResponse(status = "enabled", displayMode = "alt_block"),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.ALT_BLOCK,
cashback = expectedCashback(),
),
),
ConvertModel(
name = "enabled + missing display mode -> Enabled(FULL)",
response = createResponse(status = "enabled", displayMode = null),
expected = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = expectedCashback(),
),
),
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",
payoutCurrency: String? = "USDC",
payoutNetwork: String? = "Polygon",
) = CashbackSummaryResponse(
cashbackProgramStatus = status,
cashbackDisplayMode = displayMode,
period = period,
confirmedAmount = confirmedAmount,
pendingAmount = pendingAmount,
currency = currency,
payoutCurrency = payoutCurrency,
payoutNetwork = payoutNetwork,
)
fun expectedCashback(
confirmedAmount: BigDecimal = BigDecimal("22.54"),
pendingAmount: BigDecimal = BigDecimal("13.65"),
) = TangemPayCashback(
confirmedAmount = confirmedAmount,
pendingAmount = pendingAmount,
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"),
),
)
}
}